Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
72 changes: 57 additions & 15 deletions frontend/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,10 @@ import type { SelectModifiers } from "./components/FileRow";

export default function App() {
// Local state
const [localInfo, setLocalInfo] = useState<{ hostname: string; cwd: string }>({
const [localInfo, setLocalInfo] = useState<{ hostname: string; cwd: string; rootDir: string }>({
hostname: "...",
cwd: "...",
rootDir: "",
});
const [localEntries, setLocalEntries] = useState<FileEntry[]>([]);
const [localPath, setLocalPath] = useState(".");
Expand Down Expand Up @@ -47,17 +48,23 @@ export default function App() {
// null after refreshes/path-changes so the next click re-establishes the anchor.
const lastClickedLocalRef = useRef<number | null>(null);
const lastClickedRemoteRef = useRef<number | null>(null);
const localRootDirRef = useRef("");

const { transfers, startTransfer, updateProgress, completeTransfer, failTransfer, hasActiveTransfers } = useTransfer();

// Fetch local file listing via REST
const fetchLocal = useCallback(async (path: string): Promise<boolean> => {
setLocalLoading(true);
try {
const res = await fetch(`/api/browse?path=${encodeURIComponent(path)}`);
const root = localRootDirRef.current;
let relative = path;
if (root && path.startsWith(root)) {
relative = path.slice(root.length).replace(/^\//, "");
}
const res = await fetch(`/api/browse?path=${encodeURIComponent(relative)}`);
if (res.ok) {
const data: BrowseResponse = await res.json();
setLocalInfo({ hostname: data.hostname, cwd: data.cwd });
setLocalInfo({ hostname: data.hostname, cwd: data.cwd, rootDir: localRootDirRef.current });
Comment on lines +59 to +67
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Empty path when navigating to exact root directory.

When path === root, path.slice(root.length) produces an empty string, causing /api/browse?path= to be called. The backend's serde(default) only applies when the field is absent, not empty—this could fail or behave unexpectedly.

Line 355 in fetchLocalSuggestions correctly uses || "." as a fallback; apply the same here for consistency.

🐛 Proposed fix
       let relative = path;
       if (root && path.startsWith(root)) {
-        relative = path.slice(root.length).replace(/^\//, "");
+        relative = path.slice(root.length).replace(/^\//, "") || ".";
       }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const root = localRootDirRef.current;
let relative = path;
if (root && path.startsWith(root)) {
relative = path.slice(root.length).replace(/^\//, "");
}
const res = await fetch(`/api/browse?path=${encodeURIComponent(relative)}`);
if (res.ok) {
const data: BrowseResponse = await res.json();
setLocalInfo({ hostname: data.hostname, cwd: data.cwd });
setLocalInfo({ hostname: data.hostname, cwd: data.cwd, rootDir: localRootDirRef.current });
const root = localRootDirRef.current;
let relative = path;
if (root && path.startsWith(root)) {
relative = path.slice(root.length).replace(/^\//, "") || ".";
}
const res = await fetch(`/api/browse?path=${encodeURIComponent(relative)}`);
if (res.ok) {
const data: BrowseResponse = await res.json();
setLocalInfo({ hostname: data.hostname, cwd: data.cwd, rootDir: localRootDirRef.current });
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@frontend/src/App.tsx` around lines 59 - 67, The fetch call can send an empty
path when path === localRootDirRef.current because path.slice(root.length)
yields "", so change the computed relative used in the fetch to default to "."
when empty (e.g., after computing relative from path/root, set relative =
relative || "." or pass (relative || ".") into encodeURIComponent) so the
request uses "." for the root instead of an empty string; update the code around
localRootDirRef, the local path calculation, and the
fetch(`/api/browse?path=...`) invocation accordingly.

setLocalEntries(data.entries);
setLocalSelected(new Set());
lastClickedLocalRef.current = null;
Expand All @@ -78,6 +85,8 @@ export default function App() {
setFingerprint(info.fingerprint ?? null);
setCanReconnect(info.can_reconnect);
setLastTarget(info.last_target);
localRootDirRef.current = info.root_dir;
setLocalInfo((prev) => ({ ...prev, rootDir: info.root_dir }));
if (!info.has_remote) {
setRemoteEntries([]);
setRemoteInfo({ hostname: "...", cwd: "..." });
Expand Down Expand Up @@ -306,9 +315,13 @@ export default function App() {

const handleLocalNavigateTo = useCallback(
async (absolutePath: string) => {
const success = await fetchLocal(absolutePath);
const root = localRootDirRef.current;
const relative = root && absolutePath.startsWith(root)
? absolutePath.slice(root.length).replace(/^\//, "")
: absolutePath;
const success = await fetchLocal(relative);
if (success) {
setLocalPath(absolutePath);
setLocalPath(relative);
Comment on lines +318 to +324
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Same empty path issue in handleLocalNavigateTo.

Consistent with the issue in fetchLocal, navigating to the exact root path produces an empty string.

🐛 Proposed fix
       const relative = root && absolutePath.startsWith(root)
-        ? absolutePath.slice(root.length).replace(/^\//, "")
+        ? absolutePath.slice(root.length).replace(/^\//, "") || "."
         : absolutePath;
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@frontend/src/App.tsx` around lines 318 - 324, The computed relative path in
handleLocalNavigateTo can be an empty string when absolutePath equals
localRootDirRef.current; update the logic in handleLocalNavigateTo (the block
that computes relative, calls fetchLocal(relative), and calls setLocalPath) to
normalize an empty relative to "/" (or the app's canonical root token) before
calling fetchLocal and setLocalPath so the root navigation doesn't pass an empty
string; keep fetchLocal behavior consistent with this normalization.

} else {
setError(`Path not found: ${absolutePath}`);
setTimeout(() => setError(null), 5000);
Expand All @@ -330,8 +343,26 @@ export default function App() {
const lastSlash = inputValue.lastIndexOf("/");
const parentDir = lastSlash > 0 ? inputValue.slice(0, lastSlash) : "/";
const prefix = inputValue.slice(lastSlash + 1).toLowerCase();
const root = localRootDirRef.current;
// Use cached entries when browsing the current local cwd
if (parentDir === localInfo.cwd || inputValue === localInfo.cwd) {
return localEntries
.filter((e) => e.is_dir && e.name.toLowerCase().startsWith(prefix))
.map((e) => `${localInfo.cwd}/${e.name}`);
}
let relParent: string;
if (root && parentDir.startsWith(root)) {
relParent = parentDir.slice(root.length).replace(/^\//, "") || ".";
} else if (root && inputValue.startsWith(root)) {
// inputValue is at or below root_dir, but parentDir is outside — browse root_dir
relParent = ".";
} else if (root) {
return []; // completely outside root_dir — no suggestions
} else {
relParent = parentDir;
}
try {
const res = await fetch(`/api/browse?path=${encodeURIComponent(parentDir)}`);
const res = await fetch(`/api/browse?path=${encodeURIComponent(relParent)}`);
if (!res.ok) return [];
const data: BrowseResponse = await res.json();
return data.entries
Expand All @@ -340,20 +371,31 @@ export default function App() {
} catch {
return [];
}
}, []);
}, [localInfo.cwd, localEntries]);

// Remote suggestions come from the already-fetched remoteEntries for the current directory.
// Only suggests when the typed parent dir matches the currently viewed remote directory.
// Remote suggestions — fetch from remote server via REST (no WS side effects)
const fetchRemoteSuggestions = useCallback(async (inputValue: string): Promise<string[]> => {
if (!remoteInfo.cwd || remoteInfo.cwd === "...") return [];
if (!connected || !hasRemote) return [];
const lastSlash = inputValue.lastIndexOf("/");
const parentDir = lastSlash > 0 ? inputValue.slice(0, lastSlash) : "/";
const prefix = inputValue.slice(lastSlash + 1).toLowerCase();
if (parentDir !== remoteInfo.cwd) return [];
return remoteEntries
.filter((e) => e.is_dir && e.name.toLowerCase().startsWith(prefix))
.map((e) => `${remoteInfo.cwd}/${e.name}`);
}, [remoteEntries, remoteInfo.cwd]);
// Use cached entries when browsing the current remote cwd
if (parentDir === remoteInfo.cwd || inputValue === remoteInfo.cwd) {
return remoteEntries
.filter((e) => e.is_dir && e.name.toLowerCase().startsWith(prefix))
.map((e) => `${remoteInfo.cwd}/${e.name}`);
}
try {
const res = await fetch(`/api/browse-remote?path=${encodeURIComponent(parentDir)}`);
if (!res.ok) return [];
const data: BrowseResponse = await res.json();
return data.entries
.filter((e) => e.is_dir && e.name.toLowerCase().startsWith(prefix))
.map((e) => `${data.cwd}/${e.name}`);
} catch {
return [];
}
}, [connected, hasRemote, remoteInfo.cwd, remoteEntries]);

// Transfer actions
const handleCopyToRemote = useCallback(() => {
Expand Down
12 changes: 10 additions & 2 deletions frontend/src/components/PathBar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,13 @@ export default function PathBar({ hostname, cwd, connected, onRefresh, onNavigat
const [focused, setFocused] = useState(false);
const debounceRef = useRef<ReturnType<typeof setTimeout>>(undefined);
const inputRef = useRef<HTMLInputElement>(null);
const listRef = useRef<HTMLUListElement>(null);

useEffect(() => {
if (activeIndex < 0 || !listRef.current) return;
const active = listRef.current.children[activeIndex] as HTMLElement | undefined;
active?.scrollIntoView({ block: "nearest" });
}, [activeIndex]);

// Sync input with cwd when not focused (folder clicks, navigation)
useEffect(() => {
Expand Down Expand Up @@ -63,6 +70,7 @@ export default function PathBar({ hostname, cwd, connected, onRefresh, onNavigat
e.preventDefault();
const selected = activeIndex >= 0 ? suggestions[activeIndex] : suggestions[0];
setInputValue(selected + "/");
setSuggestions([]);
setActiveIndex(-1);
}
};
Expand All @@ -78,12 +86,12 @@ export default function PathBar({ hostname, cwd, connected, onRefresh, onNavigat
value={inputValue}
onChange={(e) => setInputValue(e.target.value)}
onKeyDown={handleKeyDown}
onFocus={(e) => { setFocused(true); e.target.select(); }}
onFocus={() => setFocused(true)}
onBlur={() => { setFocused(false); reset(); }}
className="w-full bg-transparent border border-transparent rounded px-2 py-0.5 font-mono text-xs text-zinc-300 focus:outline-none focus:border-zinc-600 focus:bg-zinc-900 focus:text-zinc-100 transition-colors"
/>
{suggestions.length > 0 && focused && (
<ul className="absolute top-full left-0 right-0 z-50 mt-0.5 max-h-48 overflow-y-auto bg-zinc-900 border border-zinc-700 rounded shadow-xl">
<ul ref={listRef} className="absolute top-full left-0 right-0 z-50 mt-0.5 max-h-48 overflow-y-auto bg-zinc-900 border border-zinc-700 rounded shadow-xl">
{suggestions.map((s, i) => (
<li
key={s}
Expand Down
Loading