From 7dd8a10634322de01d96bd52c47312c52afb4e36 Mon Sep 17 00:00:00 2001 From: Darwvin Date: Sun, 19 Jul 2026 12:13:51 +0330 Subject: [PATCH 1/7] fix remote file handling and workspace accessibility --- src-tauri/src/sftp_manager.rs | 65 ++++++++++++++++++++++++++--------- 1 file changed, 48 insertions(+), 17 deletions(-) diff --git a/src-tauri/src/sftp_manager.rs b/src-tauri/src/sftp_manager.rs index 52c9cf1..c2372a2 100644 --- a/src-tauri/src/sftp_manager.rs +++ b/src-tauri/src/sftp_manager.rs @@ -29,24 +29,14 @@ pub fn list_dir(server: &Server, path: &str) -> Result> { continue; } // perms links owner group size epoch name... - let cols: Vec<&str> = line - .splitn(7, char::is_whitespace) - .filter(|s| !s.is_empty()) - .collect(); - if cols.len() < 7 { - continue; + // `ls` pads columns with a variable number of spaces. Using `splitn` + // with `char::is_whitespace` counts those empty separators toward the + // limit and silently drops otherwise valid entries. Collecting the + // whitespace-delimited fields also preserves filenames containing + // spaces by joining everything after the six metadata columns. + if let Some(file) = parse_ls_line(line) { + files.push(file); } - let perms = cols[0]; - let size: u64 = cols[4].parse().unwrap_or(0); - let name = cols[6].to_string(); - // Strip symlink "-> target" decoration. - let name = name.split(" -> ").next().unwrap_or(&name).to_string(); - files.push(RemoteFile { - is_dir: perms.starts_with('d'), - permissions: perms.to_string(), - size, - name, - }); } files.sort_by(|a, b| { b.is_dir @@ -56,6 +46,27 @@ pub fn list_dir(server: &Server, path: &str) -> Result> { Ok(files) } +fn parse_ls_line(line: &str) -> Option { + let cols: Vec<&str> = line.split_whitespace().collect(); + if cols.len() < 7 { + return None; + } + let permissions = cols[0].to_string(); + let size = cols[4].parse().unwrap_or(0); + let decorated_name = cols[6..].join(" "); + let name = decorated_name + .split(" -> ") + .next() + .unwrap_or(&decorated_name) + .to_string(); + Some(RemoteFile { + is_dir: permissions.starts_with('d'), + permissions, + size, + name, + }) +} + /// Build the scp remote spec, honoring port/key/password. fn scp_base(server: &Server) -> (String, Vec) { let mut args = vec![ @@ -69,6 +80,8 @@ fn scp_base(server: &Server) -> (String, Vec) { if !key.trim().is_empty() { args.push("-i".into()); args.push(key.clone()); + args.push("-o".into()); + args.push("IdentitiesOnly=yes".into()); } } } else if server.auth_type == "password" { @@ -197,6 +210,24 @@ mod tests { assert_eq!(program, "scp"); assert!(args.iter().any(|a| a == "/home/user/.ssh/id_ed25519")); + assert!(has_opt(&args, "IdentitiesOnly=yes")); assert!(!has_opt(&args, "PubkeyAuthentication=no")); } + + #[test] + fn parses_padded_ls_rows_and_filenames_with_spaces() { + let line = "-rw-r--r-- 1 root root 42 1710000000 release notes.txt"; + let file = parse_ls_line(line).unwrap(); + assert_eq!(file.size, 42); + assert_eq!(file.name, "release notes.txt"); + assert!(!file.is_dir); + } + + #[test] + fn parses_directories_and_strips_symlink_targets() { + let directory = parse_ls_line("drwxr-xr-x 2 root root 4096 1710000000 releases").unwrap(); + let symlink = parse_ls_line("lrwxrwxrwx 1 root root 12 1710000000 current -> releases/v2").unwrap(); + assert!(directory.is_dir); + assert_eq!(symlink.name, "current"); + } } From b2e02fbbcc15584fb6dd5d1e17de2654f455bfc8 Mon Sep 17 00:00:00 2001 From: Darwvin Date: Sun, 19 Jul 2026 12:13:59 +0330 Subject: [PATCH 2/7] fix remote file handling and workspace accessibility --- src/components/ServerSidebar.tsx | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/src/components/ServerSidebar.tsx b/src/components/ServerSidebar.tsx index 7558a6c..b46c914 100644 --- a/src/components/ServerSidebar.tsx +++ b/src/components/ServerSidebar.tsx @@ -141,6 +141,17 @@ export function ServerSidebar({ onNew, onEdit }: Props) { className={`server-item${focusedServerId === s.id ? " active" : ""}`} onClick={() => setFocusedServer(s.id)} onDoubleClick={() => defaultConnect(s)} + onKeyDown={(event) => { + if (event.target !== event.currentTarget) return; + if (event.key === "Enter") defaultConnect(s); + else if (event.key === " ") { + event.preventDefault(); + setFocusedServer(s.id); + } + }} + role="button" + tabIndex={0} + aria-label={`${s.name}, ${s.username} at ${s.host}. Press Enter to connect.`} >
From 91d590cb6d818fe5fd6dcba544f2857131d78db9 Mon Sep 17 00:00:00 2001 From: Darwvin Date: Sun, 19 Jul 2026 12:14:04 +0330 Subject: [PATCH 3/7] fix remote file handling and workspace accessibility --- src/components/SftpPanel.tsx | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/src/components/SftpPanel.tsx b/src/components/SftpPanel.tsx index 82ac45d..3e4fffa 100644 --- a/src/components/SftpPanel.tsx +++ b/src/components/SftpPanel.tsx @@ -9,7 +9,7 @@ type FileProtocol = "sftp" | "ftp"; /** Remote file browser over SFTP/scp or plain FTP/curl. */ export function SftpPanel({ server, active, protocol = "sftp" }: { server: Server; active: boolean; protocol?: FileProtocol }) { const pushAlert = useStore((s) => s.pushAlert); - const [path, setPath] = useState(protocol === "ftp" ? "/" : `/home/${server.username}`); + const [path, setPath] = useState(protocol === "ftp" ? "/" : server.username === "root" ? "/root" : `/home/${server.username}`); const [files, setFiles] = useState([]); const [busy, setBusy] = useState(false); const [loaded, setLoaded] = useState(false); @@ -124,20 +124,21 @@ export function SftpPanel({ server, active, protocol = "sftp" }: { server: Serve
-
void list(join(path, ".."))}> +
drwx - .. +
{files.map((f) => (
{f.permissions} - f.is_dir && void list(join(path, f.name))}> + {f.is_dir ? "" : fmtSize(f.size)} - {!f.is_dir && } - - + {!f.is_dir && } + +
))} {busy &&
Working…
} From aaaed69098b62df57c84738672df33f65775b438 Mon Sep 17 00:00:00 2001 From: Darwvin Date: Sun, 19 Jul 2026 12:14:10 +0330 Subject: [PATCH 4/7] fix remote file handling and workspace accessibility --- src/components/TabBar.tsx | 22 ++++++++-------------- 1 file changed, 8 insertions(+), 14 deletions(-) diff --git a/src/components/TabBar.tsx b/src/components/TabBar.tsx index 81e1556..3599c7b 100644 --- a/src/components/TabBar.tsx +++ b/src/components/TabBar.tsx @@ -10,21 +10,15 @@ export function TabBar() { if (tabs.length === 0) return null; return ( -
+
{tabs.map((t) => ( -
setActiveTab(t.id)} - > - {t.kind} - {t.title} - { e.stopPropagation(); closeTab(t.id); }} - > - βœ• - +
+ +
))}
From 25e7369918f64f247744d585b994d80d5d55526e Mon Sep 17 00:00:00 2001 From: Darwvin Date: Sun, 19 Jul 2026 12:14:15 +0330 Subject: [PATCH 5/7] fix remote file handling and workspace accessibility --- src/store.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/store.ts b/src/store.ts index f1ade0d..47b6e0b 100644 --- a/src/store.ts +++ b/src/store.ts @@ -100,6 +100,11 @@ export const useStore = create((set, get) => ({ if (s.activeTabId === id) { const fallback = tabs[idx] ?? tabs[idx - 1] ?? tabs[tabs.length - 1] ?? null; activeTabId = fallback ? fallback.id : null; + return { + tabs, + activeTabId, + focusedServerId: fallback?.serverId ?? null, + }; } return { tabs, activeTabId }; }); From 1686e623f03765f598938229d854cc56ae9e243c Mon Sep 17 00:00:00 2001 From: Darwvin Date: Sun, 19 Jul 2026 12:14:20 +0330 Subject: [PATCH 6/7] fix remote file handling and workspace accessibility --- src/styles.css | 36 ++++++++++++++++++++++++++++-------- 1 file changed, 28 insertions(+), 8 deletions(-) diff --git a/src/styles.css b/src/styles.css index 25b670b..9adbafd 100644 --- a/src/styles.css +++ b/src/styles.css @@ -435,6 +435,7 @@ button.status-chip { box-shadow: none; } } .server-item.active::before { background: var(--accent); } +.server-item:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; } .server-item .si-top { display: flex; align-items: center; gap: 8px; } .server-item .si-name { font-weight: 700; flex: 1; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } .server-item .si-host { font-size: 11px; color: var(--text-2); font-family: var(--mono); } @@ -486,29 +487,44 @@ button.status-chip { box-shadow: none; } min-height: 42px; } -.tab { +.tab-shell { display: flex; align-items: center; - gap: 8px; - padding: 0 12px; border-right: 1px solid var(--border); - cursor: pointer; color: var(--text-1); white-space: nowrap; font-size: 12px; max-width: 260px; min-width: 124px; } +.tab { + display: flex; + align-items: center; + gap: 8px; + align-self: stretch; + flex: 1; + min-width: 0; + padding: 0 8px 0 12px; + border-top: 0; + border-bottom: 0; + border-left: 0; + border-radius: 0; + background: transparent; + box-shadow: none; + text-align: left; +} -.tab:hover { background: rgba(255, 255, 255, 0.035); } -.tab.active { +.tab-shell:hover { background: rgba(255, 255, 255, 0.035); } +.tab-shell.active { background: var(--bg-0); color: var(--text-0); box-shadow: inset 0 -2px 0 var(--accent); } .tab .tab-kind { font-size: 9px; font-weight: 800; text-transform: uppercase; color: var(--text-2); } -.tab .tab-close { color: var(--text-2); border-radius: 5px; padding: 1px 5px; margin-left: auto; } -.tab .tab-close:hover { background: var(--danger-surface); color: var(--danger-text); } +.tab-title { overflow: hidden; text-overflow: ellipsis; } +.tab-close { color: var(--text-2); border: 0; background: transparent; border-radius: 5px; padding: 3px 6px; margin-right: 5px; box-shadow: none; } +.tab-close:hover { background: var(--danger-surface); color: var(--danger-text); } +.tab-close:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; } .tab-content { flex: 1; min-height: 0; position: relative; overflow: hidden; } .tab-pane { position: absolute; inset: 0; display: flex; flex-direction: column; } @@ -1041,6 +1057,10 @@ table.data tr:hover td { background: rgba(255, 255, 255, 0.035); } .file-row:hover { background: rgba(255, 255, 255, 0.035); } .file-row .fname { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .file-row.dir .fname { color: var(--accent-2); cursor: pointer; } +.file-name-button { border: 0; border-radius: 4px; background: transparent; box-shadow: none; color: var(--text-0); padding: 2px; text-align: left; } +.file-name-button:hover:not(:disabled) { background: rgba(255, 255, 255, 0.05); } +.file-name-button:disabled { opacity: 1; cursor: default; } +.file-name-button.dir { color: var(--accent-2); cursor: pointer; } .file-row .fsize { color: var(--text-2); font-family: var(--mono); font-size: 11px; width: 90px; text-align: right; } .file-row .fperm { color: var(--text-2); font-family: var(--mono); font-size: 11px; } From cc010fb5d71727cf5510b5a40df5a82892a82cb5 Mon Sep 17 00:00:00 2001 From: Darwvin Date: Sun, 19 Jul 2026 12:14:26 +0330 Subject: [PATCH 7/7] test workspace focus fallback --- src/store.test.ts | 48 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 48 insertions(+) create mode 100644 src/store.test.ts diff --git a/src/store.test.ts b/src/store.test.ts new file mode 100644 index 0000000..82fb30c --- /dev/null +++ b/src/store.test.ts @@ -0,0 +1,48 @@ +import { beforeEach, describe, expect, it } from "vitest"; +import { useStore } from "./store"; +import type { Server } from "./types"; + +const server = (id: string): Server => ({ + id, + name: id, + host: `${id}.example.com`, + port: 22, + ftp_port: null, + rdp_port: null, + vnc_port: null, + username: "root", + protocols: ["ssh"], + auth_type: "key", + private_key_path: null, + tags: [], + group_name: null, + environment: "dev", + notes: null, + created_at: "", + updated_at: "", +}); + +describe("workspace tab focus", () => { + beforeEach(() => { + useStore.setState({ tabs: [], activeTabId: null, focusedServerId: null }); + }); + + it("focuses the fallback tab server after closing the active tab", () => { + const first = useStore.getState().openTab("ssh", server("one")); + const second = useStore.getState().openTab("ssh", server("two")); + + useStore.getState().closeTab(second); + + expect(useStore.getState().activeTabId).toBe(first); + expect(useStore.getState().focusedServerId).toBe("one"); + }); + + it("clears server focus after closing the final tab", () => { + const only = useStore.getState().openTab("ssh", server("only")); + + useStore.getState().closeTab(only); + + expect(useStore.getState().activeTabId).toBeNull(); + expect(useStore.getState().focusedServerId).toBeNull(); + }); +});