Skip to content

Commit 0f5b439

Browse files
ralyodioclaude
andauthored
Connect to ssh_config hosts, and a logo you can actually see (#11)
* desktop: let the pane connect to a host that only lives in ssh_config The server picker offers saved connections *and* hosts read from ~/.ssh/config, which are deliberately never persisted. But every remote operation resolved its id with db.findConnection(), which only ever sees saved rows — so selecting an ssh_config host threw "That connection no longer exists." and the pane rendered "Could not read this directory". On a machine with no saved connections that was every server in the list, which is exactly what it looked like from the outside: the remote pane simply did not work. Both lookups now go through resolveConnection(), which falls back to the ssh_config list for an `ssh-config:` id. Saved rows still win, so importing a host and then editing it is not undone by the file it came from. Fixes browsing, mkdir, rename, delete, test and transfers alike. Verified end to end against a real ssh_config host: `ssh-config:dev` resolves and lists its home directory over the same SFTP path the pane uses. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GTQ3RzTAey9nT6r1kbGBCd * brand: 150px logo in both the desktop header and the site The desktop lockup was 22px tall, about 66px wide — small enough to read as a favicon rather than a brand. It is now 150px wide, and the header grows from 52px to 72px so the 3:1 lockup sits in it with room above and below rather than touching the border. The site's was 240px (w-60); it comes down to the same 150px so the two surfaces match. Verified in a headless render of the real export, under the app's real CSP, in both themes: header 72px, logo 150x50, 11px clear top and bottom, no CSP refusals. The site's build emits width:150px. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GTQ3RzTAey9nT6r1kbGBCd --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent e8549d5 commit 0f5b439

6 files changed

Lines changed: 98 additions & 26 deletions

File tree

apps/desktop/electron/main/ipc.ts

Lines changed: 6 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import {
1616
TransferRequestSchema,
1717
type IpcResult,
1818
} from '../shared/contract.js'
19+
import { requireConnection } from './services/connections.js'
1920
import { browserFor, dropSession, sessionFor } from './services/sessions.js'
2021
import { store } from './services/store.js'
2122
import { cancelTransfer, previewTransfer, startTransfer } from './services/transfers.js'
@@ -71,8 +72,7 @@ export function registerIpc(): void {
7172

7273
handle(IPC.connectionsTest, z.object({ id: z.string().min(1) }), async ({ id }) => {
7374
const db = await store()
74-
const connection = await db.findConnection(id)
75-
if (!connection) throw new Error('That connection no longer exists.')
75+
const connection = await requireConnection(id)
7676

7777
const session = await sessionFor(connection)
7878
const report = await probeConnection(session, connection.rsyncPath)
@@ -154,9 +154,7 @@ export function registerIpc(): void {
154154
})
155155

156156
handle(IPC.fsListRemote, RemotePathRequestSchema, async ({ connectionId, path }) => {
157-
const db = await store()
158-
const connection = await db.findConnection(connectionId)
159-
if (!connection) throw new Error('That connection no longer exists.')
157+
const connection = await requireConnection(connectionId)
160158

161159
const browser = await browserFor(connection)
162160
try {
@@ -167,9 +165,7 @@ export function registerIpc(): void {
167165
})
168166

169167
handle(IPC.fsMkdirRemote, RemotePathRequestSchema, async ({ connectionId, path }) => {
170-
const db = await store()
171-
const connection = await db.findConnection(connectionId)
172-
if (!connection) throw new Error('That connection no longer exists.')
168+
const connection = await requireConnection(connectionId)
173169
const browser = await browserFor(connection)
174170
try {
175171
await browser.mkdir(path)
@@ -180,9 +176,7 @@ export function registerIpc(): void {
180176
})
181177

182178
handle(IPC.fsRenameRemote, RenameRequestSchema, async ({ connectionId, from, to }) => {
183-
const db = await store()
184-
const connection = await db.findConnection(connectionId)
185-
if (!connection) throw new Error('That connection no longer exists.')
179+
const connection = await requireConnection(connectionId)
186180
const browser = await browserFor(connection)
187181
try {
188182
await browser.rename(from, to)
@@ -196,9 +190,7 @@ export function registerIpc(): void {
196190
IPC.fsDeleteRemote,
197191
z.object({ connectionId: z.string().min(1), path: PathSchema, isDirectory: z.boolean() }),
198192
async ({ connectionId, path, isDirectory }) => {
199-
const db = await store()
200-
const connection = await db.findConnection(connectionId)
201-
if (!connection) throw new Error('That connection no longer exists.')
193+
const connection = await requireConnection(connectionId)
202194
const browser = await browserFor(connection)
203195
try {
204196
if (isDirectory) await browser.rmdir(path)
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
import { mkdtempSync, writeFileSync } from 'node:fs'
2+
import { tmpdir } from 'node:os'
3+
import { join } from 'node:path'
4+
import type { Connection } from '@diskpush/schemas'
5+
import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest'
6+
7+
const saved = new Map<string, Connection>()
8+
9+
vi.mock('./store.js', () => ({
10+
store: async () => ({ findConnection: async (id: string) => saved.get(id) ?? null }),
11+
}))
12+
13+
const { requireConnection, resolveConnection } = await import('./connections.js')
14+
15+
beforeAll(() => {
16+
const directory = mkdtempSync(join(tmpdir(), 'diskpush-ssh-config-'))
17+
const path = join(directory, 'config')
18+
writeFileSync(path, 'Host seed1\n HostName seed1.example.com\n User deploy\n Port 2222\n')
19+
process.env.DISKPUSH_SSH_CONFIG = path
20+
})
21+
22+
afterEach(() => saved.clear())
23+
24+
describe('resolveConnection', () => {
25+
// The bug this exists to prevent: the picker offers ~/.ssh/config hosts but
26+
// never saves them, so a database-only lookup made every one of them fail
27+
// with "That connection no longer exists" the moment it was selected — and
28+
// on a machine with no saved connections, that was every server in the list.
29+
it('resolves a host that only exists in ~/.ssh/config', async () => {
30+
const connection = await resolveConnection('ssh-config:seed1')
31+
expect(connection?.host).toBe('seed1.example.com')
32+
expect(connection?.username).toBe('deploy')
33+
expect(connection?.port).toBe(2222)
34+
})
35+
36+
it('prefers the saved row when an imported host was edited afterwards', async () => {
37+
saved.set('ssh-config:seed1', { id: 'ssh-config:seed1', host: 'edited.example.com' } as Connection)
38+
expect((await resolveConnection('ssh-config:seed1'))?.host).toBe('edited.example.com')
39+
})
40+
41+
it('resolves a saved connection by its own id', async () => {
42+
saved.set('abc-123', { id: 'abc-123', host: 'saved.example.com' } as Connection)
43+
expect((await resolveConnection('abc-123'))?.host).toBe('saved.example.com')
44+
})
45+
46+
it('does not read ssh_config for an id that never came from it', async () => {
47+
expect(await resolveConnection('deleted-uuid')).toBeNull()
48+
})
49+
50+
it('still reports a host that has since left ssh_config', async () => {
51+
expect(await resolveConnection('ssh-config:gone')).toBeNull()
52+
await expect(requireConnection('ssh-config:gone')).rejects.toThrow('That connection no longer exists.')
53+
})
54+
})
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
import { sshConfigConnections } from '@diskpush/ssh-core'
2+
import type { Connection } from '@diskpush/schemas'
3+
import { store } from './store.js'
4+
5+
/** The id prefix `sshConfigConnections()` gives hosts it reads from ~/.ssh/config. */
6+
const SSH_CONFIG_PREFIX = 'ssh-config:'
7+
8+
/**
9+
* Resolves a connection id the renderer sent back to us.
10+
*
11+
* The picker offers saved connections *and* hosts from ~/.ssh/config, and the
12+
* latter are deliberately never persisted — so looking only in the database
13+
* makes every ssh_config host unusable the moment it is selected. Saved rows
14+
* still win: importing a host and then editing it must not be undone by the
15+
* file it came from.
16+
*/
17+
export async function resolveConnection(id: string): Promise<Connection | null> {
18+
const saved = await (await store()).findConnection(id)
19+
if (saved) return saved
20+
if (!id.startsWith(SSH_CONFIG_PREFIX)) return null
21+
return sshConfigConnections().find((connection) => connection.id === id) ?? null
22+
}
23+
24+
/** As `resolveConnection`, for the callers that cannot proceed without one. */
25+
export async function requireConnection(id: string): Promise<Connection> {
26+
const connection = await resolveConnection(id)
27+
if (!connection) throw new Error('That connection no longer exists.')
28+
return connection
29+
}

apps/desktop/electron/main/services/transfers.ts

Lines changed: 4 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import { defaultRsyncOptions, summarizeChanges, type Change, type Endpoint, type
1414
import { execFile } from 'node:child_process'
1515
import { promisify } from 'node:util'
1616
import { IPC, type EndpointRef, type TransferOptions, type TransferRequest } from '../../shared/contract.js'
17+
import { requireConnection, resolveConnection } from './connections.js'
1718
import { store } from './store.js'
1819

1920
const execFileAsync = promisify(execFile)
@@ -30,9 +31,7 @@ const running = new Map<string, RunningJob>()
3031
async function resolveEndpoint(ref: EndpointRef): Promise<{ endpoint: Endpoint; connectionId: string | null }> {
3132
if (ref.type === 'local') return { endpoint: { type: 'local', path: ref.path }, connectionId: null }
3233

33-
const db = await store()
34-
const connection = await db.findConnection(ref.connectionId)
35-
if (!connection) throw new Error('That connection no longer exists.')
34+
const connection = await requireConnection(ref.connectionId)
3635

3736
return {
3837
endpoint: {
@@ -49,8 +48,7 @@ async function resolveEndpoint(ref: EndpointRef): Promise<{ endpoint: Endpoint;
4948

5049
async function shellOptionsFor(connectionId: string | null) {
5150
if (!connectionId) return {}
52-
const db = await store()
53-
const connection = await db.findConnection(connectionId)
51+
const connection = await resolveConnection(connectionId)
5452
if (!connection) return {}
5553
return {
5654
keyPath: connection.authType === 'key' || connection.authType === 'key-passphrase' ? connection.keyPath : null,
@@ -111,8 +109,7 @@ async function buildPlan(request: TransferRequest, overrides: Partial<RsyncOptio
111109
const options = { ...optionsFrom(request.options), ...overrides }
112110

113111
const isServerToServer = source.endpoint.type === 'ssh' && destination.endpoint.type === 'ssh'
114-
const db = await store()
115-
const sourceConnection = source.connectionId ? await db.findConnection(source.connectionId) : null
112+
const sourceConnection = source.connectionId ? await resolveConnection(source.connectionId) : null
116113

117114
return planTransfer({
118115
source: source.endpoint,

apps/desktop/src/app/page.tsx

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -236,7 +236,7 @@ export default function Workspace() {
236236

237237
return (
238238
<div className="flex h-full flex-col">
239-
<header className="flex h-[52px] shrink-0 items-center gap-3.5 border-b border-line bg-chrome px-4">
239+
<header className="flex h-[72px] shrink-0 items-center gap-3.5 border-b border-line bg-chrome px-4">
240240
{/*
241241
Two files, not one. logo.dark.png is the lockup FOR a dark background:
242242
its "Disk" is white, so on the light theme it vanished and the header
@@ -248,15 +248,15 @@ export default function Workspace() {
248248
alt="DiskPush"
249249
width={2172}
250250
height={724}
251-
className="hidden h-[22px] w-auto dark:block"
251+
className="hidden h-auto w-[150px] dark:block"
252252
priority
253253
/>
254254
<Image
255255
src="/logo.png"
256256
alt="DiskPush"
257257
width={2172}
258258
height={724}
259-
className="block h-[22px] w-auto dark:hidden"
259+
className="block h-auto w-[150px] dark:hidden"
260260
priority
261261
/>
262262
<div className="h-5 w-px bg-line" />

apps/web/components/chrome.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -67,9 +67,9 @@ function Logo() {
6767
alt="DiskPush"
6868
width={2172}
6969
height={724}
70-
className="logo-dark w-60 h-auto"
70+
className="logo-dark w-[150px] h-auto"
7171
/>
72-
<img src="/logo.png" alt="DiskPush" width={2172} height={724} className="logo-light w-60 h-auto" />
72+
<img src="/logo.png" alt="DiskPush" width={2172} height={724} className="logo-light w-[150px] h-auto" />
7373
</>
7474
)
7575
}

0 commit comments

Comments
 (0)