From 58389c408b344b5bd66eb8b464b593450f9725ad Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Sun, 30 Aug 2026 08:49:59 +0000 Subject: [PATCH] desktop: open symlinked directories, and re-read ssh_config when asked MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two things reported together, both about a pane showing something other than what is there. **A symlinked directory could not be opened.** `~/data -> /mnt/vdb` on a server is a row that swallowed every double-click. SFTP's readdir reports a link's type the way lstat does, so it arrives as `symlink`, and the pane only walked into rows typed `directory`. The browser now follows each link with a stat — one per link, and only for links, since a listing is mostly ordinary files — and records what is behind it. A link to a directory opens, sorts with the directories, draws as a folder in the link colour rather than as a chain, and shows no byte size. A link whose target cannot be stat'd stays unopenable, which is the honest answer for a broken one. Navigation goes to the link's own path, not the resolved target: the server resolves it when it lists, and the breadcrumbs keep saying where the user thinks they are. **The host list was read once at startup.** A host removed from ~/.ssh/config stayed in the picker for the life of the window, and one added to it never appeared at all. Opening the picker now re-reads both lists. Verified against a real server: on seed2, `data` resolves to a directory target, the pane's rule calls it navigable, and listing it returns its contents; `.tmux.conf`, a link to a file, correctly stays unopenable. Rendered headless in both themes to confirm the row sorts and draws as a folder. 9 new tests, including a stubbed SFTP layer where readdir and stat disagree, which is the whole shape of the bug. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01GTQ3RzTAey9nT6r1kbGBCd --- apps/desktop/src/app/page.tsx | 2 + .../src/components/endpoint-select.tsx | 9 ++ apps/desktop/src/components/pane.tsx | 29 ++++-- apps/desktop/src/lib/api.ts | 4 + apps/desktop/src/lib/entries.test.ts | 32 +++++++ apps/desktop/src/lib/entries.ts | 14 +++ packages/ssh-core/src/browser.test.ts | 91 +++++++++++++++++++ packages/ssh-core/src/browser.ts | 44 ++++++++- 8 files changed, 218 insertions(+), 7 deletions(-) create mode 100644 apps/desktop/src/lib/entries.test.ts create mode 100644 apps/desktop/src/lib/entries.ts create mode 100644 packages/ssh-core/src/browser.test.ts diff --git a/apps/desktop/src/app/page.tsx b/apps/desktop/src/app/page.tsx index f553af1..dda8de6 100644 --- a/apps/desktop/src/app/page.tsx +++ b/apps/desktop/src/app/page.tsx @@ -346,6 +346,7 @@ export default function Workspace() { state={left} saved={saved} sshConfig={sshConfig} + onRefreshHosts={refreshConnections} active={active === 'left'} onFocus={() => setActive('left')} onChange={(patch) => setLeft((current) => ({ ...current, ...patch }))} @@ -371,6 +372,7 @@ export default function Workspace() { state={right} saved={saved} sshConfig={sshConfig} + onRefreshHosts={refreshConnections} active={active === 'right'} onFocus={() => setActive('right')} onChange={(patch) => setRight((current) => ({ ...current, ...patch }))} diff --git a/apps/desktop/src/components/endpoint-select.tsx b/apps/desktop/src/components/endpoint-select.tsx index 81bae0c..0c5a6f1 100644 --- a/apps/desktop/src/components/endpoint-select.tsx +++ b/apps/desktop/src/components/endpoint-select.tsx @@ -37,12 +37,15 @@ export function EndpointSelect({ sshConfig, onChange, onAddServer, + onOpen, }: { value: PaneEndpoint saved: readonly Connection[] sshConfig: readonly Connection[] onChange: (endpoint: PaneEndpoint) => void onAddServer: () => void + /** Re-reads the host lists, so an edited ~/.ssh/config shows without a restart. */ + onOpen?: () => void }) { const current = value.kind === 'local' ? LOCAL : value.connectionId const selectedLabel = @@ -53,6 +56,12 @@ export function EndpointSelect({ return (