Skip to content
Merged
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
65 changes: 48 additions & 17 deletions src-tauri/src/sftp_manager.rs
Original file line number Diff line number Diff line change
Expand Up @@ -29,24 +29,14 @@ pub fn list_dir(server: &Server, path: &str) -> Result<Vec<RemoteFile>> {
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
Expand All @@ -56,6 +46,27 @@ pub fn list_dir(server: &Server, path: &str) -> Result<Vec<RemoteFile>> {
Ok(files)
}

fn parse_ls_line(line: &str) -> Option<RemoteFile> {
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<String>) {
let mut args = vec![
Expand All @@ -69,6 +80,8 @@ fn scp_base(server: &Server) -> (String, Vec<String>) {
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" {
Expand Down Expand Up @@ -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");
}
}
11 changes: 11 additions & 0 deletions src/components/ServerSidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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.`}
>
<div className="si-top">
<span className={`env-dot env-${s.environment}`} title={s.environment} />
Expand Down
17 changes: 9 additions & 8 deletions src/components/SftpPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<RemoteFile[]>([]);
const [busy, setBusy] = useState(false);
const [loaded, setLoaded] = useState(false);
Expand Down Expand Up @@ -124,20 +124,21 @@ export function SftpPanel({ server, active, protocol = "sftp" }: { server: Serve
<button className="tiny primary" disabled={busy} onClick={() => void upload()}>⬆ Upload</button>
</div>
<div className="sftp-list">
<div className="file-row" style={{ color: "var(--text-2)" }} onClick={() => void list(join(path, ".."))}>
<div className="file-row" style={{ color: "var(--text-2)" }}>
<span className="fperm">drwx</span>
<span className="fname dir">..</span>
<button type="button" className="fname file-name-button dir" onClick={() => void list(join(path, ".."))}>..</button>
</div>
{files.map((f) => (
<div key={f.name} className={`file-row${f.is_dir ? " dir" : ""}`}>
<span className="fperm">{f.permissions}</span>
<span className="fname" onClick={() => f.is_dir && void list(join(path, f.name))}>
<button type="button" className="fname file-name-button" disabled={!f.is_dir}
onClick={() => f.is_dir && void list(join(path, f.name))} title={f.name}>
{f.is_dir ? "📁 " : "📄 "}{f.name}
</span>
</button>
<span className="fsize">{f.is_dir ? "" : fmtSize(f.size)}</span>
{!f.is_dir && <button className="tiny ghost" onClick={() => void download(f)}>⬇</button>}
<button className="tiny ghost" onClick={() => void rename(f)}>✎</button>
<button className="tiny ghost" onClick={() => void remove(f)}>🗑</button>
{!f.is_dir && <button className="tiny ghost" aria-label={`Download ${f.name}`} onClick={() => void download(f)}>⬇</button>}
<button className="tiny ghost" aria-label={`Rename ${f.name}`} onClick={() => void rename(f)}>✎</button>
<button className="tiny ghost danger-ghost" aria-label={`Delete ${f.name}`} onClick={() => void remove(f)}>🗑</button>
</div>
))}
{busy && <div className="panel-hint">Working…</div>}
Expand Down
22 changes: 8 additions & 14 deletions src/components/TabBar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,21 +10,15 @@ export function TabBar() {
if (tabs.length === 0) return null;

return (
<div className="tabbar">
<div className="tabbar" role="tablist" aria-label="Open sessions">
{tabs.map((t) => (
<div
key={t.id}
className={`tab${activeTabId === t.id ? " active" : ""}`}
onClick={() => setActiveTab(t.id)}
>
<span className="tab-kind">{t.kind}</span>
<span style={{ overflow: "hidden", textOverflow: "ellipsis" }}>{t.title}</span>
<span
className="tab-close"
onClick={(e) => { e.stopPropagation(); closeTab(t.id); }}
>
</span>
<div key={t.id} className={`tab-shell${activeTabId === t.id ? " active" : ""}`}>
<button type="button" className="tab" onClick={() => setActiveTab(t.id)} role="tab"
aria-selected={activeTabId === t.id} tabIndex={activeTabId === t.id ? 0 : -1}>
<span className="tab-kind">{t.kind}</span>
<span className="tab-title">{t.title}</span>
</button>
<button type="button" className="tab-close" aria-label={`Close ${t.title}`} onClick={() => closeTab(t.id)}>✕</button>
</div>
))}
</div>
Expand Down
48 changes: 48 additions & 0 deletions src/store.test.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
5 changes: 5 additions & 0 deletions src/store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,11 @@ export const useStore = create<AppStore>((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 };
});
Expand Down
36 changes: 28 additions & 8 deletions src/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -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); }
Expand Down Expand Up @@ -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; }

Expand Down Expand Up @@ -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; }

Expand Down
Loading