onItemClick(row.original)}
onDoubleClick={() => onItemDoubleClick(row.original)}
>
{row.getVisibleCells().map(cell => (
|
diff --git a/frontend/src/components/ui/BrowsePage/BreadcrumbSegment.tsx b/frontend/src/components/ui/widgets/BreadcrumbSegment.tsx
similarity index 100%
rename from frontend/src/components/ui/BrowsePage/BreadcrumbSegment.tsx
rename to frontend/src/components/ui/widgets/BreadcrumbSegment.tsx
diff --git a/frontend/src/hooks/useFileSelector.ts b/frontend/src/hooks/useFileSelector.ts
index b79867e95..b95a2a4e4 100644
--- a/frontend/src/hooks/useFileSelector.ts
+++ b/frontend/src/hooks/useFileSelector.ts
@@ -1,10 +1,14 @@
import { useState, useCallback, useEffect, useMemo, useRef } from 'react';
+import type { ChangeEvent } from 'react';
import { useZoneAndFspMapContext } from '@/contexts/ZonesAndFspMapContext';
import { usePreferencesContext } from '@/contexts/PreferencesContext';
import { useProfileContext } from '@/contexts/ProfileContext';
import useFileQuery from '@/queries/fileQueries';
-import { getPreferredPathForDisplay } from '@/utils/pathHandling';
+import {
+ getPreferredPathForDisplay,
+ resolvePathToFsp
+} from '@/utils/pathHandling';
import { makeMapKey } from '@/utils';
import { filterFspsByGroupMembership } from '@/utils/groupFiltering';
import type { FileOrFolder, FileSharePath, Zone } from '@/shared.types';
@@ -19,7 +23,8 @@ type FileSelectorState = {
selectedItem: {
name: string;
isDir: boolean;
- fullPath: string; // Full filesystem path in preferred format
+ fullPath: string; // Path in effective format (may be overridden for server use)
+ displayPath: string; // Path in user's preferred format for display
} | null;
};
@@ -62,6 +67,22 @@ export default function useFileSelector(options?: FileSelectorOptions) {
selectedItem: null
});
+ const [searchQuery, setSearchQuery] = useState('');
+ const normalizedQuery = searchQuery.trim().toLowerCase();
+
+ const handleSearchChange = useCallback(
+ (event: ChangeEvent) => {
+ setSearchQuery(event.target.value);
+ },
+ []
+ );
+
+ const clearSearch = useCallback(() => {
+ setSearchQuery('');
+ }, []);
+
+ const userHasGroups = (profile?.groups?.length ?? 0) > 0;
+
// Resolve initialPath (raw filesystem path) to FSP + relative path
const lastResolvedPath = useRef(undefined);
useEffect(() => {
@@ -75,35 +96,10 @@ export default function useFileSelector(options?: FileSelectorOptions) {
}
lastResolvedPath.current = initialPath;
- // Find the FSP whose mount_path is the longest prefix of initialPath
- let bestFsp: FileSharePath | null = null;
- let bestMountPath = '';
- for (const [key, value] of Object.entries(zonesAndFspQuery.data)) {
- if (!key.startsWith('fsp_')) {
- continue;
- }
- const fsp = value as FileSharePath;
- const mountPath = fsp.mount_path;
- if (
- mountPath &&
- initialPath.startsWith(mountPath) &&
- mountPath.length > bestMountPath.length
- ) {
- // Ensure it's a proper prefix (matches at a path boundary)
- const rest = initialPath.slice(mountPath.length);
- if (rest === '' || rest.startsWith('/')) {
- bestFsp = fsp;
- bestMountPath = mountPath;
- }
- }
- }
+ const resolved = resolvePathToFsp(initialPath, zonesAndFspQuery.data);
- if (bestFsp) {
- let subPath = initialPath.slice(bestMountPath.length);
- // Remove leading slash from subpath
- if (subPath.startsWith('/')) {
- subPath = subPath.slice(1);
- }
+ if (resolved) {
+ let subPath = resolved.subpath;
// For file mode, navigate to the parent directory
if (subPath && mode !== 'directory') {
const lastSlash = subPath.lastIndexOf('/');
@@ -117,7 +113,7 @@ export default function useFileSelector(options?: FileSelectorOptions) {
setState({
currentLocation: {
type: 'filesystem',
- fspName: bestFsp.name,
+ fspName: resolved.fsp.name,
path: subPath || '.'
},
selectedItem: null
@@ -185,6 +181,13 @@ export default function useFileSelector(options?: FileSelectorOptions) {
}
});
+ // Filter zones by search query
+ if (normalizedQuery) {
+ return items.filter(item =>
+ item.name.toLowerCase().includes(normalizedQuery)
+ );
+ }
+
return items;
} else if (state.currentLocation.type === 'zone') {
// Show FSPs in the selected zone
@@ -209,8 +212,15 @@ export default function useFileSelector(options?: FileSelectorOptions) {
isFilteredByGroups
);
+ // Filter FSPs by search query
+ const searchFilteredFsps = normalizedQuery
+ ? accessibleFsps.filter(fsp =>
+ fsp.name.toLowerCase().includes(normalizedQuery)
+ )
+ : accessibleFsps;
+
// Convert to FileOrFolder items to display in file selector table
- const items: FileOrFolder[] = accessibleFsps.map(fsp => ({
+ const items: FileOrFolder[] = searchFilteredFsps.map(fsp => ({
name: fsp.name,
path: fsp.name,
is_dir: true,
@@ -224,7 +234,13 @@ export default function useFileSelector(options?: FileSelectorOptions) {
return items;
} else {
// In filesystem mode, return files from query
- return fileQuery.data?.files || [];
+ const files = fileQuery.data?.files || [];
+ if (normalizedQuery) {
+ return files.filter(item =>
+ item.name.toLowerCase().includes(normalizedQuery)
+ );
+ }
+ return files;
}
}, [
state.currentLocation,
@@ -232,20 +248,23 @@ export default function useFileSelector(options?: FileSelectorOptions) {
zonesAndFspQuery.isPending,
fileQuery.data,
isFilteredByGroups,
- profile
+ profile,
+ normalizedQuery
]);
// Navigation methods
- const navigateToLocation = (location: FileSelectorLocation) => {
+ const navigateToLocation = useCallback((location: FileSelectorLocation) => {
+ setSearchQuery('');
setState({
currentLocation: location,
selectedItem: null
});
- };
+ }, []);
// Reset to initial state (for when dialog is closed/cancelled)
const reset = useCallback(() => {
lastResolvedPath.current = undefined;
+ setSearchQuery('');
setState({
currentLocation: initialLocation
? {
@@ -263,6 +282,7 @@ export default function useFileSelector(options?: FileSelectorOptions) {
const selectItem = useCallback(
(item?: FileOrFolder) => {
let fullPath = '';
+ let displayPath = '';
let name = '';
let isDir = true;
@@ -273,10 +293,17 @@ export default function useFileSelector(options?: FileSelectorOptions) {
return;
}
+ const subPath =
+ state.currentLocation.path === '.' ? '' : state.currentLocation.path;
fullPath = getPreferredPathForDisplay(
effectivePathPreference,
currentFsp,
- state.currentLocation.path === '.' ? '' : state.currentLocation.path
+ subPath
+ );
+ displayPath = getPreferredPathForDisplay(
+ pathPreference,
+ currentFsp,
+ subPath
);
// Get the folder name from the path
@@ -303,6 +330,7 @@ export default function useFileSelector(options?: FileSelectorOptions) {
const fsp = zonesAndFspQuery.data?.[fspKey] as FileSharePath;
if (fsp) {
fullPath = getPreferredPathForDisplay(effectivePathPreference, fsp);
+ displayPath = getPreferredPathForDisplay(pathPreference, fsp);
}
} else if (currentFsp) {
// In filesystem mode, generate path from current FSP + item path
@@ -311,6 +339,11 @@ export default function useFileSelector(options?: FileSelectorOptions) {
currentFsp,
item.path
);
+ displayPath = getPreferredPathForDisplay(
+ pathPreference,
+ currentFsp,
+ item.path
+ );
}
name = item.name;
@@ -324,7 +357,8 @@ export default function useFileSelector(options?: FileSelectorOptions) {
selectedItem: {
name,
isDir,
- fullPath
+ fullPath,
+ displayPath
}
}));
}
@@ -333,6 +367,7 @@ export default function useFileSelector(options?: FileSelectorOptions) {
state.currentLocation,
currentFsp,
effectivePathPreference,
+ pathPreference,
mode,
zonesAndFspQuery.data
]
@@ -342,39 +377,26 @@ export default function useFileSelector(options?: FileSelectorOptions) {
const handleItemDoubleClick = useCallback(
(item: FileOrFolder) => {
if (!item.is_dir) {
- // Can't navigate into files
return;
}
if (state.currentLocation.type === 'zones') {
- // Navigate to zone
- setState({
- currentLocation: { type: 'zone', zoneId: item.name },
- selectedItem: null
- });
+ navigateToLocation({ type: 'zone', zoneId: item.name });
} else if (state.currentLocation.type === 'zone') {
- // Navigate to FSP
- setState({
- currentLocation: {
- type: 'filesystem',
- fspName: item.name,
- path: '.'
- },
- selectedItem: null
+ navigateToLocation({
+ type: 'filesystem',
+ fspName: item.name,
+ path: '.'
});
} else if (state.currentLocation.type === 'filesystem') {
- // Navigate to folder
- setState({
- currentLocation: {
- type: 'filesystem',
- fspName: state.currentLocation.fspName,
- path: item.path
- },
- selectedItem: null
+ navigateToLocation({
+ type: 'filesystem',
+ fspName: state.currentLocation.fspName,
+ path: item.path
});
}
},
- [state.currentLocation]
+ [state.currentLocation, navigateToLocation]
);
return {
@@ -385,6 +407,11 @@ export default function useFileSelector(options?: FileSelectorOptions) {
navigateToLocation,
selectItem,
handleItemDoubleClick,
- reset
+ reset,
+ searchQuery,
+ handleSearchChange,
+ clearSearch,
+ isFilteredByGroups,
+ userHasGroups
};
}
diff --git a/frontend/src/hooks/useNavigationInput.ts b/frontend/src/hooks/useNavigationInput.ts
index ff664c090..eea2d13ff 100644
--- a/frontend/src/hooks/useNavigationInput.ts
+++ b/frontend/src/hooks/useNavigationInput.ts
@@ -3,11 +3,8 @@ import type { ChangeEvent } from 'react';
import { useNavigate } from 'react-router';
import { useZoneAndFspMapContext } from '@/contexts/ZonesAndFspMapContext';
-import { FileSharePath, Result } from '@/shared.types';
-import {
- convertBackToForwardSlash,
- makeBrowseLink
-} from '@/utils/pathHandling';
+import type { Result } from '@/shared.types';
+import { makeBrowseLink, resolvePathToFsp } from '@/utils/pathHandling';
import { createSuccess, handleError } from '@/utils/errorHandling';
export default function useNavigationInput(initialValue: string = '') {
@@ -41,64 +38,11 @@ export default function useNavigationInput(initialValue: string = '') {
}
try {
- // Trim white space and, if necessary, convert backslashes to forward slashes
- const normalizedInput = convertBackToForwardSlash(inputValue.trim());
+ const resolved = resolvePathToFsp(inputValue, zonesAndFspQuery.data);
- // Track best match
- let bestMatch: {
- fspObject: FileSharePath;
- matchedPath: string;
- subpath: string;
- } | null = null;
-
- const keys = Object.keys(zonesAndFspQuery.data);
- for (const key of keys) {
- // Iterate through only the objects in zonesAndFileSharePathsMap that have a key that start with "fsp_"
- if (key.startsWith('fsp_')) {
- const fspObject = zonesAndFspQuery.data[key] as FileSharePath;
- const linuxPath = fspObject.linux_path || '';
- const macPath = fspObject.mac_path || '';
- const windowsPath = convertBackToForwardSlash(fspObject.windows_path);
-
- let matchedPath: string | null = null;
- let subpath = '';
- // Check if the normalized input starts with any of the mount paths
- // If a match is found, extract the subpath
- // Collect all potential matches
- if (normalizedInput.startsWith(linuxPath)) {
- matchedPath = linuxPath;
- subpath = normalizedInput.replace(linuxPath, '');
- } else if (normalizedInput.startsWith(macPath)) {
- matchedPath = macPath;
- subpath = normalizedInput.replace(macPath, '');
- } else if (normalizedInput.startsWith(windowsPath)) {
- matchedPath = windowsPath;
- subpath = normalizedInput.replace(windowsPath, '');
- }
-
- if (matchedPath) {
- // The best match is the one with the longest matched path (most specific)
- if (
- !bestMatch ||
- matchedPath.length > bestMatch.matchedPath.length
- ) {
- bestMatch = {
- fspObject,
- matchedPath,
- subpath
- };
- }
- }
- }
- }
-
- if (bestMatch) {
- const browseLink = makeBrowseLink(
- bestMatch.fspObject.name,
- bestMatch.subpath
- );
+ if (resolved) {
+ const browseLink = makeBrowseLink(resolved.fsp.name, resolved.subpath);
navigate(browseLink);
- // Clear the inputValue
setInputValue('');
return createSuccess(undefined);
} else {
diff --git a/frontend/src/utils/groupFiltering.ts b/frontend/src/utils/groupFiltering.ts
index 95c2e387d..b03b70683 100644
--- a/frontend/src/utils/groupFiltering.ts
+++ b/frontend/src/utils/groupFiltering.ts
@@ -9,7 +9,11 @@ import type { FileSharePath } from '@/shared.types';
* @returns true if the user has access, false otherwise
*/
function shouldDisplayFsp(fsp: FileSharePath, userGroups: string[]): boolean {
- return userGroups.includes(fsp.group) || fsp.group === 'public';
+ return (
+ userGroups.includes(fsp.group) ||
+ fsp.group === 'public' ||
+ fsp.group === 'local'
+ );
}
/**
diff --git a/frontend/src/utils/pathHandling.ts b/frontend/src/utils/pathHandling.ts
index 3891bce23..710c66af9 100644
--- a/frontend/src/utils/pathHandling.ts
+++ b/frontend/src/utils/pathHandling.ts
@@ -202,6 +202,61 @@ function makeBrowseLink(
return `/browse/${escapedFspName}`;
}
+/**
+ * Resolves a raw filesystem path (in any format: Linux, Mac, or Windows) to the
+ * best-matching FSP and remaining subpath. Used by both the navigation input and
+ * the file selector to accept pasted paths in any OS format.
+ *
+ * Returns null if no FSP matches the given path.
+ */
+function resolvePathToFsp(
+ rawPath: string,
+ zonesAndFspData: Record
+): { fsp: FileSharePath; subpath: string } | null {
+ const normalizedInput = convertBackToForwardSlash(rawPath.trim());
+
+ let bestFsp: FileSharePath | null = null;
+ let bestMountPath = '';
+
+ for (const [key, value] of Object.entries(zonesAndFspData)) {
+ if (!key.startsWith('fsp_')) {
+ continue;
+ }
+ const fsp = value as FileSharePath;
+
+ const candidatePaths = [
+ fsp.mount_path,
+ fsp.linux_path,
+ fsp.mac_path,
+ fsp.windows_path ? convertBackToForwardSlash(fsp.windows_path) : null
+ ];
+
+ for (const candidatePath of candidatePaths) {
+ if (
+ candidatePath &&
+ normalizedInput.startsWith(candidatePath) &&
+ candidatePath.length > bestMountPath.length
+ ) {
+ const rest = normalizedInput.slice(candidatePath.length);
+ if (rest === '' || rest.startsWith('/')) {
+ bestFsp = fsp;
+ bestMountPath = candidatePath;
+ }
+ }
+ }
+ }
+
+ if (!bestFsp) {
+ return null;
+ }
+
+ let subpath = normalizedInput.slice(bestMountPath.length);
+ if (subpath.startsWith('/')) {
+ subpath = subpath.slice(1);
+ }
+ return { fsp: bestFsp, subpath };
+}
+
export {
convertBackToForwardSlash,
escapePathForUrl,
@@ -213,5 +268,6 @@ export {
makePathSegmentArray,
normalizePosixStylePath,
removeLastSegmentFromPath,
- removeTrailingSlashes
+ removeTrailingSlashes,
+ resolvePathToFsp
};
diff --git a/tests/test_apps.py b/tests/test_apps.py
index 9da0dd7d9..35e99f367 100644
--- a/tests/test_apps.py
+++ b/tests/test_apps.py
@@ -13,6 +13,7 @@
verify_requirements,
_container_sif_name,
_build_container_script,
+ build_command,
)
@@ -525,3 +526,45 @@ def test_syntax_error_short_circuits(self):
error = validate_path_in_filestore("/data;bad", mock_session)
assert error is not None
assert "invalid characters" in error
+
+
+
+class TestBuildCommandTildeExpansion:
+ """build_command expands ~ in file/directory params so shlex quoting works."""
+
+ @pytest.fixture()
+ def entry_point(self):
+ return AppEntryPoint(
+ id="test",
+ name="test",
+ command="test_cmd",
+ parameters=[
+ {
+ "key": "output_dir",
+ "name": "Output Directory",
+ "type": "directory",
+ "flag": "--output_dir",
+ }
+ ],
+ )
+
+ def test_tilde_expanded_in_directory_param(self, entry_point):
+ import os
+ cmd = build_command(entry_point, {"output_dir": "~/data/output"})
+ home = os.path.expanduser("~")
+ expected = f"{home}/data/output"
+ assert expected in cmd
+ assert "~" not in cmd
+
+ def test_bare_tilde_expanded(self, entry_point):
+ import os
+ cmd = build_command(entry_point, {"output_dir": "~"})
+ home = os.path.expanduser("~")
+ assert home in cmd
+ assert "~" not in cmd
+
+ def test_absolute_path_unchanged(self, entry_point):
+ cmd = build_command(entry_point, {"output_dir": "/data/output"})
+ assert "/data/output" in cmd
+
+
diff --git a/tests/test_database.py b/tests/test_database.py
index ac6a8a178..93ae91d54 100644
--- a/tests/test_database.py
+++ b/tests/test_database.py
@@ -2,12 +2,15 @@
import os
import shutil
from datetime import datetime
+from unittest.mock import patch, MagicMock
import pytest
import pandas as pd
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from fileglancer.database import *
+from fileglancer.database import _find_best_fsp_match
+from fileglancer.model import FileSharePath
from fileglancer.utils import slugify_path
def create_file_share_path_dicts(df):
@@ -419,3 +422,86 @@ def test_find_fsp_from_absolute_path_with_symlink_resolution(db_session, temp_di
finally:
shutil.rmtree(symlink_container)
+
+# --- _find_best_fsp_match tests ---
+
+class TestFindBestFspMatch:
+ """Unit tests for the shared _find_best_fsp_match helper."""
+
+ @pytest.fixture()
+ def fsps(self):
+ return [
+ FileSharePath(
+ zone="z", name="short",
+ mount_path="/mnt/short",
+ linux_path="/linux/short",
+ mac_path="smb://server/short",
+ ),
+ FileSharePath(
+ zone="z", name="long",
+ mount_path="/mnt/long",
+ linux_path="/linux/short/nested",
+ mac_path="smb://server/short/nested",
+ ),
+ ]
+
+ def test_longest_match_wins(self, fsps):
+ result = _find_best_fsp_match(
+ fsps, "smb://server/short/nested/file.txt",
+ lambda fsp: [fsp.mac_path],
+ )
+ assert result is not None
+ assert result[0].name == "long"
+ assert result[1] == "file.txt"
+
+ def test_boundary_safety(self, fsps):
+ """A prefix that doesn't end on a separator boundary must not match."""
+ result = _find_best_fsp_match(
+ fsps, "/linux/shortcut/file.txt",
+ lambda fsp: [fsp.linux_path],
+ )
+ assert result is None
+
+ def test_exact_match_returns_empty_subpath(self, fsps):
+ result = _find_best_fsp_match(
+ fsps, "/linux/short",
+ lambda fsp: [fsp.linux_path],
+ )
+ assert result is not None
+ assert result[0].name == "short"
+ assert result[1] == ""
+
+ def test_none_candidates_are_skipped(self):
+ fsp = FileSharePath(zone="z", name="test", mount_path="/mnt/test")
+ result = _find_best_fsp_match(
+ [fsp], "/mnt/test/file",
+ lambda f: [None, f.mount_path, None],
+ )
+ assert result is not None
+ assert result[1] == "file"
+
+ def test_custom_separator(self):
+ """os.sep separator is respected for boundary checks."""
+ fsp = FileSharePath(zone="z", name="test", mount_path="/mnt/test")
+ # With "/" separator, this should match
+ result_slash = _find_best_fsp_match(
+ [fsp], "/mnt/test/sub",
+ lambda f: [f.mount_path],
+ separator="/",
+ )
+ assert result_slash is not None
+ assert result_slash[1] == "sub"
+
+ def test_no_fsps_returns_none(self):
+ result = _find_best_fsp_match([], "/any/path", lambda f: [f.mount_path])
+ assert result is None
+
+ def test_no_matching_candidate_returns_none(self):
+ fsp = FileSharePath(zone="z", name="test", mount_path="/mnt/test")
+ result = _find_best_fsp_match(
+ [fsp], "/completely/different",
+ lambda f: [f.mount_path],
+ )
+ assert result is None
+
+
|