From 953c63ebf93fbafcc8585b42096711f8543f8862 Mon Sep 17 00:00:00 2001 From: Zhichang Yu Date: Wed, 8 Apr 2026 13:25:08 +0800 Subject: [PATCH 1/3] feat: display project path in sidebar and allow remote projects with same path but different hosts - Display project path in sidebar after project name: - Local projects show absolute path - Remote projects show hostname:path (e.g., tower01:/home/user/repo) - Fix ProjectConflictError for remote projects by allowing same path with different sshConnectionId (different SSH hosts) - Remove unique index on projects.path (with migration) since remote projects can share paths when connected via different SSH hosts - Add SSH config alias support: when connectionId starts with "ssh-config:", resolve via SSH config parser instead of DB lookup - Fix connection error display: show connectionId as fallback when remote.host is unavailable - Add unit tests for remote-vs-remote project path collisions Co-Authored-By: Claude Opus 4.6 --- drizzle/0013_remove_projects_path_unique.sql | 2 + drizzle/meta/0013_snapshot.json | 13 ++++ drizzle/meta/_journal.json | 7 +++ src/main/db/schema.ts | 3 +- src/main/ipc/sshIpc.ts | 32 ++++++++++ src/main/services/DatabaseService.ts | 21 +++++-- .../components/sidebar/LeftSidebar.tsx | 15 ++++- .../main/DatabaseService.saveProject.test.ts | 62 +++++++++++++++++++ 8 files changed, 146 insertions(+), 9 deletions(-) create mode 100644 drizzle/0013_remove_projects_path_unique.sql create mode 100644 drizzle/meta/0013_snapshot.json diff --git a/drizzle/0013_remove_projects_path_unique.sql b/drizzle/0013_remove_projects_path_unique.sql new file mode 100644 index 000000000..c402774b0 --- /dev/null +++ b/drizzle/0013_remove_projects_path_unique.sql @@ -0,0 +1,2 @@ +-- Drop the unique index on projects.path to allow remote projects with the same path but different sshConnectionId +DROP INDEX IF EXISTS idx_projects_path; diff --git a/drizzle/meta/0013_snapshot.json b/drizzle/meta/0013_snapshot.json new file mode 100644 index 000000000..be2d51ec9 --- /dev/null +++ b/drizzle/meta/0013_snapshot.json @@ -0,0 +1,13 @@ +{ + "id": "d2c07f6e-5672-45a9-a9f5-fec5931b3ce2", + "prevId": "00000000-0000-0000-0000-000000000000", + "version": "6", + "dialect": "sqlite", + "tables": {}, + "enums": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index 966386a2e..8da7015a4 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -92,6 +92,13 @@ "when": 1743033600000, "tag": "0012_add_automation_triggers", "breakpoints": true + }, + { + "idx": 13, + "version": "6", + "when": 1775625447056, + "tag": "0013_remove_projects_path_unique", + "breakpoints": true } ] } diff --git a/src/main/db/schema.ts b/src/main/db/schema.ts index f20adcd24..789ab4e8e 100644 --- a/src/main/db/schema.ts +++ b/src/main/db/schema.ts @@ -49,7 +49,8 @@ export const projects = sqliteTable( .default(sql`CURRENT_TIMESTAMP`), }, (table) => ({ - pathIdx: uniqueIndex('idx_projects_path').on(table.path), + // Note: path uniqueness is enforced at the application layer in DatabaseService.saveProject + // to allow remote projects with the same path but different sshConnectionId. sshConnectionIdIdx: index('idx_projects_ssh_connection_id').on(table.sshConnectionId), isRemoteIdx: index('idx_projects_is_remote').on(table.isRemote), }) diff --git a/src/main/ipc/sshIpc.ts b/src/main/ipc/sshIpc.ts index 0c85707dd..801049f33 100644 --- a/src/main/ipc/sshIpc.ts +++ b/src/main/ipc/sshIpc.ts @@ -14,6 +14,7 @@ import { parseSshConfigFile, resolveIdentityAgent, resolveProxyCommand, + resolveSshConfigHost, } from '../utils/sshConfigParser'; import type { SshConfig, @@ -450,6 +451,37 @@ export function registerSshIpc() { // Accept either a saved connection id (string) or a config object. if (typeof arg === 'string') { const id = arg; + + // Handle SSH config aliases (e.g. "ssh-config:tower02") that were never saved to DB + if (id.startsWith('ssh-config:')) { + const raw = id.slice('ssh-config:'.length); + const alias = /%[0-9A-Fa-f]{2}/.test(raw) ? decodeURIComponent(raw) : raw; + + const sshConfigHost = await resolveSshConfigHost(alias); + if (!sshConfigHost) { + return { success: false, error: `SSH config host not found: ${alias}` }; + } + + const config: SshConfig = { + id, + name: alias, + host: sshConfigHost.hostname ?? alias, + port: sshConfigHost.port ?? 22, + username: sshConfigHost.user ?? '', + authType: sshConfigHost.identityFile ? 'key' : 'agent', + privateKeyPath: sshConfigHost.identityFile, + useAgent: !sshConfigHost.identityFile, + }; + + const connectionId = await sshService.connect(config); + monitor.startMonitoring(connectionId, config); + monitor.updateState(connectionId, 'connected'); + void import('../telemetry').then(({ capture }) => { + void capture('ssh_connect_success', { type: config.authType }); + }); + return { success: true, connectionId }; + } + const { db } = await getDrizzleClient(); const rows = await db .select({ diff --git a/src/main/services/DatabaseService.ts b/src/main/services/DatabaseService.ts index 75a2ee27d..7b469b325 100644 --- a/src/main/services/DatabaseService.ts +++ b/src/main/services/DatabaseService.ts @@ -208,6 +208,8 @@ export class DatabaseService { id: projectsTable.id, name: projectsTable.name, path: projectsTable.path, + sshConnectionId: projectsTable.sshConnectionId, + isRemote: projectsTable.isRemote, }) .from(projectsTable) .where(eq(projectsTable.path, project.path)) @@ -215,11 +217,20 @@ export class DatabaseService { const existingByPath = existingByPathRows[0] ?? null; if (existingByPath && existingByPath.id !== project.id) { - throw new ProjectConflictError({ - existingProjectId: existingByPath.id, - existingProjectName: existingByPath.name, - projectPath: existingByPath.path, - }); + // For remote projects, allow the same path if sshConnectionId is different + const isNewProjectRemote = project.isRemote && project.sshConnectionId; + const isExistingProjectRemote = + existingByPath.isRemote === 1 && existingByPath.sshConnectionId; + const sameSshConnection = + project.sshConnectionId && existingByPath.sshConnectionId === project.sshConnectionId; + + if (!isNewProjectRemote || !isExistingProjectRemote || sameSshConnection) { + throw new ProjectConflictError({ + existingProjectId: existingByPath.id, + existingProjectName: existingByPath.name, + projectPath: existingByPath.path, + }); + } } const values = { diff --git a/src/renderer/components/sidebar/LeftSidebar.tsx b/src/renderer/components/sidebar/LeftSidebar.tsx index 03d5e7d1e..08a3e8d16 100644 --- a/src/renderer/components/sidebar/LeftSidebar.tsx +++ b/src/renderer/components/sidebar/LeftSidebar.tsx @@ -84,9 +84,18 @@ interface ProjectItemProps { const ProjectItem = React.memo(({ project }) => { const remote = useRemoteProject(project); const connectionId = getConnectionId(project); + const isRemote = isRemoteProject(project); - if (!connectionId && !isRemoteProject(project)) { - return {project.name}; + const displayName = useMemo(() => { + if (isRemote) { + const host = remote.host ?? connectionId ?? ''; + return `${project.name} (${host}:${project.path})`; + } + return `${project.name} (${project.path})`; + }, [project, isRemote, remote.host, connectionId]); + + if (!connectionId && !isRemote) { + return {displayName}; } return ( @@ -100,7 +109,7 @@ const ProjectItem = React.memo(({ project }) => { disabled={remote.isLoading} /> )} - {project.name} + {displayName} ); }); diff --git a/src/test/main/DatabaseService.saveProject.test.ts b/src/test/main/DatabaseService.saveProject.test.ts index f2dcf087a..c5bd7db50 100644 --- a/src/test/main/DatabaseService.saveProject.test.ts +++ b/src/test/main/DatabaseService.saveProject.test.ts @@ -168,4 +168,66 @@ describe('DatabaseService.saveProject', () => { }) ); }); + + it('throws ProjectConflictError when both remote projects share the same sshConnectionId and path', async () => { + // Both projects are remote with the same sshConnectionId — conflict + selectResults.push([]); + selectResults.push([ + { + id: 'project-existing', + name: 'Existing Remote', + path: '/srv/project-one', + isRemote: 1, + sshConnectionId: 'ssh-1', + }, + ]); + + await expect(service.saveProject(baseProject)).rejects.toEqual( + expect.objectContaining({ + name: 'ProjectConflictError', + code: 'PROJECT_CONFLICT', + existingProjectId: 'project-existing', + existingProjectName: 'Existing Remote', + projectPath: '/srv/project-one', + }) + ); + + expect(insertValuesMock).not.toHaveBeenCalled(); + expect(updateValuesMock).not.toHaveBeenCalled(); + }); + + it('allows two remote projects with the same path but different sshConnectionId', async () => { + // Both projects are remote with the same path but DIFFERENT sshConnectionId — allowed + selectResults.push([]); + selectResults.push([ + { + id: 'project-existing', + name: 'Existing Remote on Host B', + path: '/srv/project-one', + isRemote: 1, + sshConnectionId: 'ssh-host-b', + }, + ]); + + // New project on a different host (ssh-host-a) — same path, different connection + const remoteProjectOnDifferentHost: Omit = { + ...baseProject, + id: 'project-2', + name: 'Remote Project on Host A', + path: '/srv/project-one', // same path as existingByPath + sshConnectionId: 'ssh-host-a', + }; + + await expect(service.saveProject(remoteProjectOnDifferentHost)).resolves.toBeUndefined(); + + // Should have inserted a new row, not updated or thrown + expect(insertValuesMock).toHaveBeenCalledWith( + expect.objectContaining({ + id: 'project-2', + path: '/srv/project-one', + sshConnectionId: 'ssh-host-a', + }) + ); + expect(updateValuesMock).not.toHaveBeenCalled(); + }); }); From f6dd06fdb8de1ceb497d5fa293352ab59f426051 Mon Sep 17 00:00:00 2001 From: Zhichang Yu Date: Wed, 8 Apr 2026 13:38:50 +0800 Subject: [PATCH 2/3] fix(sshIpc): default username to os.userInfo().username when SSH config lacks user When connecting via SSH config alias (e.g., "ssh-config:tower02"), the username from sshConfigHost.user may be undefined. Previously defaulted to empty string which causes SSH auth failures. Now falls back to the current OS username via os.userInfo().username. Co-Authored-By: Claude Opus 4.6 --- src/main/ipc/sshIpc.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/main/ipc/sshIpc.ts b/src/main/ipc/sshIpc.ts index 801049f33..9de43c079 100644 --- a/src/main/ipc/sshIpc.ts +++ b/src/main/ipc/sshIpc.ts @@ -1,4 +1,5 @@ import { ipcMain } from 'electron'; +import { userInfo } from 'os'; import { SSH_IPC_CHANNELS } from '../../shared/ssh/types'; import { sshService } from '../services/ssh/SshService'; import { SshCredentialService } from '../services/ssh/SshCredentialService'; @@ -467,7 +468,7 @@ export function registerSshIpc() { name: alias, host: sshConfigHost.hostname ?? alias, port: sshConfigHost.port ?? 22, - username: sshConfigHost.user ?? '', + username: sshConfigHost.user ?? userInfo().username, authType: sshConfigHost.identityFile ? 'key' : 'agent', privateKeyPath: sshConfigHost.identityFile, useAgent: !sshConfigHost.identityFile, From 8d2d91e9087d792afb59e4e823751978825f999a Mon Sep 17 00:00:00 2001 From: Zhichang Yu Date: Wed, 8 Apr 2026 13:52:23 +0800 Subject: [PATCH 3/3] fix(sshIpc): respect identityAgent for useAgent when connecting via SSH config alias When connecting via SSH config alias, identityAgent (e.g. SSH_AUTH_SOCK) should take priority over identityFile for determining auth type and useAgent. Previously useAgent was always false when identityFile was set, ignoring identityAgent which represents explicit intent to use SSH agent forwarding. Co-Authored-By: Claude Opus 4.6 --- src/main/ipc/sshIpc.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/main/ipc/sshIpc.ts b/src/main/ipc/sshIpc.ts index 9de43c079..99cad2e0a 100644 --- a/src/main/ipc/sshIpc.ts +++ b/src/main/ipc/sshIpc.ts @@ -469,9 +469,13 @@ export function registerSshIpc() { host: sshConfigHost.hostname ?? alias, port: sshConfigHost.port ?? 22, username: sshConfigHost.user ?? userInfo().username, - authType: sshConfigHost.identityFile ? 'key' : 'agent', + authType: sshConfigHost.identityAgent + ? 'agent' + : sshConfigHost.identityFile + ? 'key' + : 'agent', privateKeyPath: sshConfigHost.identityFile, - useAgent: !sshConfigHost.identityFile, + useAgent: Boolean(sshConfigHost.identityAgent), }; const connectionId = await sshService.connect(config);