From 20c0fdd784d8874655f097a88095f7a52348486e Mon Sep 17 00:00:00 2001 From: Lars Kappert <9989650+yigitkonur@users.noreply.github.com> Date: Tue, 17 Feb 2026 16:46:15 -0800 Subject: [PATCH 1/8] feat(desktop): WIP ssh support and remote clipboard image paste --- .mcp.json | 32 - apps/desktop/electron-builder.ts | 12 +- apps/desktop/package.json | 2 + apps/desktop/scripts/setup-signing.sh | 385 +++++ apps/desktop/src/lib/trpc/routers/index.ts | 2 + .../src/lib/trpc/routers/projects/projects.ts | 129 ++ .../src/lib/trpc/routers/terminal/terminal.ts | 143 +- .../routers/workspaces/procedures/create.ts | 75 +- .../routers/workspaces/procedures/query.ts | 50 +- .../trpc/routers/workspaces/utils/worktree.ts | 5 + .../src/main/lib/workspace-runtime/index.ts | 1 + .../main/lib/workspace-runtime/registry.ts | 94 +- .../remote-ssh/clipboard-service.ts | 201 +++ .../NewWorkspaceModal/NewWorkspaceModal.tsx | 347 ++++- .../renderer/components/Paywall/usePaywall.ts | 5 +- apps/desktop/src/renderer/env.renderer.ts | 5 +- .../hooks/useVersionCheck/useVersionCheck.ts | 10 + apps/desktop/src/renderer/index.html | 4 +- .../src/renderer/lib/api-trpc-client.ts | 17 + apps/desktop/src/renderer/lib/auth-client.ts | 36 + .../providers/AuthProvider/AuthProvider.tsx | 10 + .../CollectionsProvider.tsx | 2 + .../CollectionsProvider/collections.ts | 105 +- .../SettingsSidebar/GeneralSettings.tsx | 11 +- .../components/SshConnectionsSettings.tsx | 442 ++++++ .../settings/ssh-connections/page.tsx | 24 + .../utils/settings-search/settings-search.ts | 40 + .../main/components/StartView/index.tsx | 36 +- .../ProjectSection/ProjectSection.tsx | 10 +- .../CollapsedWorkspaceItem.tsx | 22 +- .../WorkspaceListItem/WorkspaceIcon.tsx | 13 +- .../WorkspaceListItem/WorkspaceListItem.tsx | 43 +- .../DeleteWorkspaceDialog.tsx | 4 +- .../TabsContent/Terminal/Terminal.tsx | 21 +- .../TabsContent/Terminal/helpers.ts | 23 + .../Terminal/hooks/useClipboardImagePaste.ts | 76 + .../Terminal/hooks/useTerminalConnection.ts | 38 +- .../Terminal/hooks/useTerminalLifecycle.ts | 8 +- .../ContentView/TabsContent/Terminal/types.ts | 4 + .../WorkspacesListView/WorkspacesListView.tsx | 1 + .../components/WorkspacesListView/types.ts | 3 +- .../src/renderer/stores/settings-state.ts | 1 + .../desktop/src/renderer/stores/tabs/store.ts | 6 +- .../stores/tabs/utils/terminal-cleanup.ts | 13 +- apps/desktop/src/shared/constants.ts | 3 + bun.lock | 22 + package.json | 7 +- .../drizzle/0027_add_project_type.sql | 1 + .../drizzle/0028_add_missing_ssh_columns.sql | 4 + .../local-db/drizzle/meta/0027_snapshot.json | 1321 +++++++++++++++++ .../local-db/drizzle/meta/0028_snapshot.json | 1321 +++++++++++++++++ packages/local-db/drizzle/meta/_journal.json | 14 + packages/local-db/src/schema/relations.ts | 13 +- packages/local-db/src/schema/schema.ts | 42 +- packages/local-db/src/schema/zod.ts | 27 +- 55 files changed, 5110 insertions(+), 176 deletions(-) delete mode 100644 .mcp.json create mode 100755 apps/desktop/scripts/setup-signing.sh create mode 100644 apps/desktop/src/main/lib/workspace-runtime/remote-ssh/clipboard-service.ts create mode 100644 apps/desktop/src/renderer/routes/_authenticated/settings/ssh-connections/components/SshConnectionsSettings.tsx create mode 100644 apps/desktop/src/renderer/routes/_authenticated/settings/ssh-connections/page.tsx create mode 100644 apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/hooks/useClipboardImagePaste.ts create mode 100644 packages/local-db/drizzle/0027_add_project_type.sql create mode 100644 packages/local-db/drizzle/0028_add_missing_ssh_columns.sql create mode 100644 packages/local-db/drizzle/meta/0027_snapshot.json create mode 100644 packages/local-db/drizzle/meta/0028_snapshot.json diff --git a/.mcp.json b/.mcp.json deleted file mode 100644 index 8851a2af1cb..00000000000 --- a/.mcp.json +++ /dev/null @@ -1,32 +0,0 @@ -{ - "mcpServers": { - "superset": { - "type": "http", - "url": "https://api.superset.sh/api/agent/mcp" - }, - "expo-mcp": { - "type": "http", - "url": "https://mcp.expo.dev/mcp" - }, - "maestro": { - "command": "maestro", - "args": ["mcp"] - }, - "neon": { - "type": "http", - "url": "https://mcp.neon.tech/mcp" - }, - "linear": { - "type": "http", - "url": "https://mcp.linear.app/mcp" - }, - "sentry": { - "type": "http", - "url": "https://mcp.sentry.dev/mcp" - }, - "desktop-automation": { - "command": "bun", - "args": ["run", "packages/desktop-mcp/src/bin.ts"] - } - } -} diff --git a/apps/desktop/electron-builder.ts b/apps/desktop/electron-builder.ts index 9218df3bdd0..2235de9930b 100644 --- a/apps/desktop/electron-builder.ts +++ b/apps/desktop/electron-builder.ts @@ -127,7 +127,17 @@ const config: Configuration = { ], hardenedRuntime: true, gatekeeperAssess: false, - notarize: true, + entitlements: join(pkg.resources, "build/entitlements.mac.plist"), + entitlementsInherit: join( + pkg.resources, + "build/entitlements.mac.inherit.plist", + ), + notarize: + process.env.APPLE_NOTARIZE === "true" + ? { + teamId: process.env.APPLE_TEAM_ID || "", + } + : false, extendInfo: { CFBundleName: productName, CFBundleDisplayName: productName, diff --git a/apps/desktop/package.json b/apps/desktop/package.json index a523fe5dc54..a2c73f0cdd4 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -162,6 +162,7 @@ "semver": "^7.7.3", "shell-quote": "^1.8.3", "simple-git": "^3.30.0", + "ssh2": "^1.16.0", "strip-ansi": "^7.1.2", "superjson": "^2.2.5", "tailwind-merge": "^3.4.0", @@ -190,6 +191,7 @@ "@types/react-syntax-highlighter": "^15.5.13", "@types/semver": "^7.7.1", "@types/shell-quote": "^1.7.5", + "@types/ssh2": "^1.15.4", "@vitejs/plugin-react": "^5.0.1", "bun-types": "^1.3.1", "code-inspector-plugin": "^1.2.2", diff --git a/apps/desktop/scripts/setup-signing.sh b/apps/desktop/scripts/setup-signing.sh new file mode 100755 index 00000000000..c683030d041 --- /dev/null +++ b/apps/desktop/scripts/setup-signing.sh @@ -0,0 +1,385 @@ +#!/usr/bin/env bash +set -euo pipefail + +# ============================================================================= +# macOS Code Signing & Notarization Setup for Superset Desktop +# ============================================================================= +# +# This script helps set up code signing for local builds. +# +# Prerequisites: +# - Apple Developer account ($99/year) at https://developer.apple.com +# - Xcode or Xcode Command Line Tools installed +# +# Usage: +# ./scripts/setup-signing.sh # Interactive setup +# ./scripts/setup-signing.sh --check # Check current signing status +# ./scripts/setup-signing.sh --build # Build signed .dmg +# ============================================================================= + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +DESKTOP_DIR="$(cd "$SCRIPT_DIR/.." && pwd)" +ENV_FILE="$DESKTOP_DIR/.env.signing" + +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +CYAN='\033[0;36m' +NC='\033[0m' + +info() { echo -e "${CYAN}[info]${NC} $*"; } +ok() { echo -e "${GREEN}[ok]${NC} $*"; } +warn() { echo -e "${YELLOW}[warn]${NC} $*"; } +err() { echo -e "${RED}[error]${NC} $*"; } + +# ----------------------------------------------------------------------------- +# Check: list signing identities +# ----------------------------------------------------------------------------- +check_identities() { + echo "" + info "Scanning Keychain for code signing identities..." + echo "" + + local dev_id_app + dev_id_app=$(security find-identity -v -p codesigning | grep "Developer ID Application" || true) + + local apple_dev + apple_dev=$(security find-identity -v -p codesigning | grep "Apple Development" || true) + + local apple_dist + apple_dist=$(security find-identity -v -p codesigning | grep "Apple Distribution" || true) + + if [ -n "$dev_id_app" ]; then + ok "Developer ID Application (for distribution outside App Store):" + echo " $dev_id_app" + else + warn "No 'Developer ID Application' certificate found." + echo " This is needed to distribute signed builds outside the App Store." + echo " Create one at: https://developer.apple.com/account/resources/certificates/add" + echo " Select: 'Developer ID Application'" + fi + + echo "" + + if [ -n "$apple_dev" ]; then + ok "Apple Development (for local testing):" + echo " $apple_dev" + else + warn "No 'Apple Development' certificate found." + fi + + if [ -n "$apple_dist" ]; then + ok "Apple Distribution:" + echo " $apple_dist" + fi + + echo "" + info "All identities:" + security find-identity -v -p codesigning + echo "" +} + +# ----------------------------------------------------------------------------- +# Check: current signing setup status +# ----------------------------------------------------------------------------- +check_status() { + echo "" + echo "==========================================" + echo " Superset Desktop - Signing Status" + echo "==========================================" + + # Certificates + check_identities + + # Entitlements + local ent_parent="$DESKTOP_DIR/src/resources/build/entitlements.mac.plist" + local ent_child="$DESKTOP_DIR/src/resources/build/entitlements.mac.inherit.plist" + + if [ -f "$ent_parent" ]; then + ok "Parent entitlements: $ent_parent" + else + err "Missing parent entitlements: $ent_parent" + fi + + if [ -f "$ent_child" ]; then + ok "Child entitlements: $ent_child" + else + err "Missing child entitlements: $ent_child" + fi + + # Env file + echo "" + if [ -f "$ENV_FILE" ]; then + ok "Signing env file: $ENV_FILE" + echo " Contents (secrets redacted):" + while IFS= read -r line; do + if [[ "$line" =~ ^# ]] || [[ -z "$line" ]]; then + continue + fi + key="${line%%=*}" + val="${line#*=}" + if [[ "$key" == *"PASSWORD"* ]] || [[ "$key" == *"SECRET"* ]] || [[ "$key" == *"KEY"* ]]; then + echo " $key=****" + else + echo " $line" + fi + done < "$ENV_FILE" + else + warn "No .env.signing file found. Run this script without --check to create one." + fi + + echo "" +} + +# ----------------------------------------------------------------------------- +# Interactive setup +# ----------------------------------------------------------------------------- +setup() { + echo "" + echo "==========================================" + echo " Superset Desktop - Signing Setup" + echo "==========================================" + echo "" + + # Step 1: Check certificates + info "Step 1: Checking certificates..." + check_identities + + local dev_id_app + dev_id_app=$(security find-identity -v -p codesigning | grep "Developer ID Application" | head -1 || true) + + local identity="" + + if [ -n "$dev_id_app" ]; then + # Extract the identity name between quotes + identity=$(echo "$dev_id_app" | sed 's/.*"\(.*\)".*/\1/') + ok "Found: $identity" + echo "" + read -rp "Use this identity? [Y/n] " use_found + if [[ "$use_found" =~ ^[Nn] ]]; then + identity="" + fi + fi + + if [ -z "$identity" ]; then + echo "" + warn "No Developer ID Application certificate found." + echo "" + echo "You have two options:" + echo "" + echo " 1) Create a 'Developer ID Application' certificate" + echo " - Go to https://developer.apple.com/account/resources/certificates/add" + echo " - Select 'Developer ID Application'" + echo " - Follow the CSR process (Keychain Access > Certificate Assistant > Request...)" + echo " - Download and double-click the .cer to install" + echo "" + echo " 2) Use your existing Apple Development cert (for local testing only)" + echo "" + + local apple_dev + apple_dev=$(security find-identity -v -p codesigning | grep "Apple Development" | head -1 || true) + + if [ -n "$apple_dev" ]; then + identity=$(echo "$apple_dev" | sed 's/.*"\(.*\)".*/\1/') + read -rp "Use '$identity' for local builds? [Y/n] " use_dev + if [[ "$use_dev" =~ ^[Nn] ]]; then + echo "" + read -rp "Enter signing identity manually (or press Enter to skip): " identity + fi + else + read -rp "Enter signing identity manually (or press Enter to skip): " identity + fi + fi + + # Step 2: Team ID + echo "" + info "Step 2: Apple Team ID" + echo " Find your Team ID at: https://developer.apple.com/account#MembershipDetailsCard" + echo "" + + local team_id="" + read -rp "Enter your Apple Team ID (10-char alphanumeric): " team_id + + # Step 3: Notarization (only needed for distribution) + echo "" + info "Step 3: Notarization setup (optional, for distribution builds)" + echo "" + echo " Notarization requires either:" + echo " a) Apple ID + app-specific password" + echo " b) App Store Connect API key (recommended for CI)" + echo "" + + local notarize="false" + local apple_id="" + local apple_password="" + local api_key="" + local api_key_id="" + local api_issuer="" + + read -rp "Set up notarization? [y/N] " do_notarize + if [[ "$do_notarize" =~ ^[Yy] ]]; then + notarize="true" + echo "" + echo " Choose method:" + echo " 1) Apple ID + app-specific password" + echo " 2) App Store Connect API key" + echo "" + read -rp " Method [1/2]: " method + + if [ "$method" = "2" ]; then + echo "" + echo " Create an API key at: https://appstoreconnect.apple.com/access/integrations/api" + echo " Download the .p8 key file and note the Key ID and Issuer ID." + echo "" + read -rp " Path to .p8 key file: " api_key + read -rp " API Key ID: " api_key_id + read -rp " Issuer ID: " api_issuer + else + echo "" + echo " Create an app-specific password at: https://appleid.apple.com/account/manage" + echo " (Sign in > App-Specific Passwords > Generate)" + echo "" + read -rp " Apple ID (email): " apple_id + read -rsp " App-specific password: " apple_password + echo "" + fi + fi + + # Step 4: Write .env.signing + echo "" + info "Step 4: Writing $ENV_FILE" + + cat > "$ENV_FILE" <> "$ENV_FILE" <> "$ENV_FILE" </dev/null; then + echo ".env.signing" >> "$gitignore" + ok "Added .env.signing to .gitignore" + fi + else + echo ".env.signing" > "$gitignore" + ok "Created .gitignore with .env.signing" + fi + + echo "" + echo "==========================================" + echo " Setup complete!" + echo "==========================================" + echo "" + echo " To build a signed app:" + echo "" + echo " source apps/desktop/.env.signing" + echo " cd apps/desktop" + echo " bun run prebuild && bun run package" + echo "" + echo " Or use the shortcut:" + echo "" + echo " ./scripts/setup-signing.sh --build" + echo "" +} + +# ----------------------------------------------------------------------------- +# Build signed .dmg +# ----------------------------------------------------------------------------- +build_signed() { + echo "" + info "Building signed Superset Desktop..." + echo "" + + if [ -f "$ENV_FILE" ]; then + info "Loading $ENV_FILE" + set -a + # shellcheck disable=SC1090 + source "$ENV_FILE" + set +a + else + err "No .env.signing found. Run: ./scripts/setup-signing.sh" + exit 1 + fi + + if [ -z "${CSC_NAME:-}" ]; then + err "CSC_NAME not set. Run setup first." + exit 1 + fi + + info "Signing with: $CSC_NAME" + info "Team ID: ${APPLE_TEAM_ID:-not set}" + info "Notarize: ${APPLE_NOTARIZE:-false}" + echo "" + + cd "$DESKTOP_DIR" + + info "Compiling app..." + bun run prebuild + + info "Packaging & signing..." + bun run package + + echo "" + ok "Build complete! Output in: $DESKTOP_DIR/release/" + ls -lh "$DESKTOP_DIR/release/"*.dmg 2>/dev/null || ls -lh "$DESKTOP_DIR/release/" || true + echo "" +} + +# ----------------------------------------------------------------------------- +# Main +# ----------------------------------------------------------------------------- +case "${1:-}" in + --check) + check_status + ;; + --build) + build_signed + ;; + --help|-h) + echo "Usage: $0 [--check|--build|--help]" + echo "" + echo " (no args) Interactive signing setup" + echo " --check Show current signing status" + echo " --build Build a signed .dmg" + echo " --help Show this help" + ;; + *) + setup + ;; +esac diff --git a/apps/desktop/src/lib/trpc/routers/index.ts b/apps/desktop/src/lib/trpc/routers/index.ts index 59ebc0263b5..0334aa3ae27 100644 --- a/apps/desktop/src/lib/trpc/routers/index.ts +++ b/apps/desktop/src/lib/trpc/routers/index.ts @@ -18,6 +18,7 @@ import { createPortsRouter } from "./ports"; import { createProjectsRouter } from "./projects"; import { createRingtoneRouter } from "./ringtone"; import { createSettingsRouter } from "./settings"; +import { createSshConnectionsRouter } from "./ssh-connections"; import { createTerminalRouter } from "./terminal"; import { createUiStateRouter } from "./ui-state"; import { createWindowRouter } from "./window"; @@ -44,6 +45,7 @@ export const createAppRouter = (getWindow: () => BrowserWindow | null) => { hotkeys: createHotkeysRouter(getWindow), external: createExternalRouter(), settings: createSettingsRouter(), + sshConnections: createSshConnectionsRouter(), config: createConfigRouter(), uiState: createUiStateRouter(), ringtone: createRingtoneRouter(), diff --git a/apps/desktop/src/lib/trpc/routers/projects/projects.ts b/apps/desktop/src/lib/trpc/routers/projects/projects.ts index d50017c7792..dd727864253 100644 --- a/apps/desktop/src/lib/trpc/routers/projects/projects.ts +++ b/apps/desktop/src/lib/trpc/routers/projects/projects.ts @@ -6,6 +6,7 @@ import { projects, type SelectProject, settings, + sshConnections, workspaces, } from "@superset/local-db"; import { TRPCError } from "@trpc/server"; @@ -26,6 +27,7 @@ import { publicProcedure, router } from "../.."; import { activateProject, getBranchWorkspace, + getMaxWorkspaceTabOrder, setLastActiveWorkspace, touchWorkspace, } from "../workspaces/utils/db-helpers"; @@ -43,6 +45,10 @@ import { fetchGitHubOwner, getGitHubAvatarUrl } from "./utils/github"; type Project = SelectProject; +function isRemoteProject(project: Project): boolean { + return project.projectType === "remote"; +} + // Return types for openNew procedure (single project) type OpenNewCanceled = { canceled: true }; type OpenNewError = { canceled: false; error: string }; @@ -306,6 +312,10 @@ export const createProjectsRouter = (getWindow: () => BrowserWindow | null) => { throw new Error(`Project ${input.projectId} not found`); } + if (isRemoteProject(project)) { + return { branches: [], defaultBranch: "main" }; + } + const git = simpleGit(project.mainRepoPath); // Check if origin remote exists @@ -890,6 +900,10 @@ export const createProjectsRouter = (getWindow: () => BrowserWindow | null) => { throw new Error(`Project ${input.id} not found`); } + if (isRemoteProject(project)) { + return { success: true, defaultBranch: "main", changed: false }; + } + const remoteDefaultBranch = await refreshDefaultBranch( project.mainRepoPath, ); @@ -1024,6 +1038,10 @@ export const createProjectsRouter = (getWindow: () => BrowserWindow | null) => { return null; } + if (isRemoteProject(project)) { + return null; + } + if (project.githubOwner) { console.log( "[getGitHubAvatar] Using cached owner:", @@ -1073,6 +1091,10 @@ export const createProjectsRouter = (getWindow: () => BrowserWindow | null) => { return null; } + if (isRemoteProject(project)) { + return null; + } + const authorName = await getGitAuthorName(project.mainRepoPath); if (!authorName) { return null; @@ -1100,6 +1122,10 @@ export const createProjectsRouter = (getWindow: () => BrowserWindow | null) => { }); } + if (isRemoteProject(project)) { + return { iconUrl: null }; + } + // Skip if the project already has an icon if (project.iconUrl) { return { iconUrl: project.iconUrl }; @@ -1167,6 +1193,109 @@ export const createProjectsRouter = (getWindow: () => BrowserWindow | null) => { return { iconUrl }; }), + + createRemote: publicProcedure + .input( + z.object({ + sshConnectionId: z.string(), + remotePath: z.string().min(1), + name: z.string().optional(), + }), + ) + .mutation(async ({ input }) => { + // Validate SSH connection exists + const sshConn = localDb + .select() + .from(sshConnections) + .where(eq(sshConnections.id, input.sshConnectionId)) + .get(); + if (!sshConn) { + throw new TRPCError({ + code: "NOT_FOUND", + message: `SSH connection ${input.sshConnectionId} not found`, + }); + } + + // Dedup: check for existing remote project with same path and SSH connection + const allRemoteProjects = localDb + .select() + .from(projects) + .where( + and( + eq(projects.mainRepoPath, input.remotePath), + eq(projects.projectType, "remote"), + ), + ) + .all(); + + for (const existingProject of allRemoteProjects) { + const existingWorkspace = localDb + .select() + .from(workspaces) + .where( + and( + eq(workspaces.projectId, existingProject.id), + eq(workspaces.type, "remote"), + eq(workspaces.sshConnectionId, input.sshConnectionId), + isNull(workspaces.deletingAt), + ), + ) + .get(); + + if (existingWorkspace) { + // Reactivate existing project + workspace + touchWorkspace(existingWorkspace.id); + setLastActiveWorkspace(existingWorkspace.id); + activateProject(existingProject); + return { project: existingProject, workspace: existingWorkspace }; + } + } + + // Create new remote project + const projectName = + input.name || input.remotePath.split("/").pop() || "remote-project"; + + const project = localDb + .insert(projects) + .values({ + mainRepoPath: input.remotePath, + name: projectName, + color: getDefaultProjectColor(), + defaultBranch: "main", + projectType: "remote", + }) + .returning() + .get(); + + // Create remote workspace + const maxTabOrder = getMaxWorkspaceTabOrder(project.id); + const workspaceName = `${sshConn.name}:${input.remotePath.split("/").pop() || "remote"}`; + + const workspace = localDb + .insert(workspaces) + .values({ + projectId: project.id, + type: "remote", + branch: "remote", + name: workspaceName, + sshConnectionId: input.sshConnectionId, + remotePath: input.remotePath, + tabOrder: maxTabOrder + 1, + }) + .returning() + .get(); + + setLastActiveWorkspace(workspace.id); + activateProject(project); + + track("project_created", { + project_id: project.id, + type: "remote", + ssh_host: sshConn.host, + }); + + return { project, workspace }; + }), }); }; diff --git a/apps/desktop/src/lib/trpc/routers/terminal/terminal.ts b/apps/desktop/src/lib/trpc/routers/terminal/terminal.ts index e3dc1d6e364..9e4c68b71df 100644 --- a/apps/desktop/src/lib/trpc/routers/terminal/terminal.ts +++ b/apps/desktop/src/lib/trpc/routers/terminal/terminal.ts @@ -32,8 +32,8 @@ const SAFE_ID = z ); /** - * Terminal router using daemon-backed terminal runtime - * Sessions are keyed by paneId and linked to workspaces for cwd resolution + * Terminal router using workspace-selected terminal runtimes. + * Sessions are keyed by paneId and linked to workspaces for runtime + cwd resolution. * * Environment variables set for terminal sessions: * - PATH: Prepends ~/.superset/bin so wrapper scripts intercept agent commands @@ -47,11 +47,15 @@ const SAFE_ID = z */ export const createTerminalRouter = () => { const registry = getWorkspaceRuntimeRegistry(); - const terminal = registry.getDefault().terminal; + const defaultTerminal = registry.getDefault().terminal; + const getTerminalForWorkspace = (workspaceId?: string) => { + if (!workspaceId) return defaultTerminal; + return registry.getForWorkspaceId(workspaceId).terminal; + }; if (DEBUG_TERMINAL) { console.log( "[Terminal Router] Using terminal runtime, capabilities:", - terminal.capabilities, + defaultTerminal.capabilities, ); } @@ -98,7 +102,10 @@ export const createTerminalRouter = () => { if (workspace?.type === "worktree") { assertWorkspaceUsable(workspaceId, workspacePath); } - const cwd = resolveCwd(cwdOverride, workspacePath); + const cwd = + workspace?.type === "remote" + ? (cwdOverride ?? workspacePath ?? undefined) + : resolveCwd(cwdOverride, workspacePath); if (DEBUG_TERMINAL) { console.log("[Terminal Router] createOrAttach called:", { @@ -125,6 +132,7 @@ export const createTerminalRouter = () => { }); try { + const terminal = getTerminalForWorkspace(workspaceId); const result = await terminal.createOrAttach({ paneId, tabId, @@ -199,10 +207,12 @@ export const createTerminalRouter = () => { .input( z.object({ paneId: z.string(), + workspaceId: z.string().optional(), data: z.string(), }), ) .mutation(async ({ input }) => { + const terminal = getTerminalForWorkspace(input.workspaceId); try { terminal.write(input); } catch (error) { @@ -225,19 +235,21 @@ export const createTerminalRouter = () => { ackColdRestore: publicProcedure .input(z.object({ paneId: z.string() })) .mutation(({ input }) => { - terminal.ackColdRestore(input.paneId); + defaultTerminal.ackColdRestore(input.paneId); }), resize: publicProcedure .input( z.object({ paneId: z.string(), + workspaceId: z.string().optional(), cols: z.number(), rows: z.number(), seq: z.number().optional(), }), ) .mutation(async ({ input }) => { + const terminal = getTerminalForWorkspace(input.workspaceId); terminal.resize(input); }), @@ -245,10 +257,12 @@ export const createTerminalRouter = () => { .input( z.object({ paneId: z.string(), + workspaceId: z.string().optional(), signal: z.string().optional(), }), ) .mutation(async ({ input }) => { + const terminal = getTerminalForWorkspace(input.workspaceId); terminal.signal(input); }), @@ -256,9 +270,11 @@ export const createTerminalRouter = () => { .input( z.object({ paneId: z.string(), + workspaceId: z.string().optional(), }), ) .mutation(async ({ input }) => { + const terminal = getTerminalForWorkspace(input.workspaceId); await terminal.kill(input); }), @@ -266,9 +282,11 @@ export const createTerminalRouter = () => { .input( z.object({ paneId: z.string(), + workspaceId: z.string().optional(), }), ) .mutation(async ({ input }) => { + const terminal = getTerminalForWorkspace(input.workspaceId); terminal.detach(input); }), @@ -276,20 +294,22 @@ export const createTerminalRouter = () => { .input( z.object({ paneId: z.string(), + workspaceId: z.string().optional(), }), ) .mutation(async ({ input }) => { + const terminal = getTerminalForWorkspace(input.workspaceId); await terminal.clearScrollback(input); }), listDaemonSessions: publicProcedure.query(async () => { - const { sessions } = await terminal.management.listSessions(); + const { sessions } = await defaultTerminal.management.listSessions(); return { sessions }; }), killAllDaemonSessions: publicProcedure.mutation(async () => { const client = getTerminalHostClient(); - const before = await terminal.management.listSessions(); + const before = await defaultTerminal.management.listSessions(); const beforeIds = before.sessions.map((s) => s.sessionId); console.log( "[killAllDaemonSessions] Before kill:", @@ -300,7 +320,7 @@ export const createTerminalRouter = () => { if (beforeIds.length > 0) { const results = await Promise.allSettled( - beforeIds.map((paneId) => terminal.kill({ paneId })), + beforeIds.map((paneId) => defaultTerminal.kill({ paneId })), ); for (const [index, result] of results.entries()) { if (result.status === "rejected") { @@ -354,7 +374,7 @@ export const createTerminalRouter = () => { killDaemonSessionsForWorkspace: publicProcedure .input(z.object({ workspaceId: z.string() })) .mutation(async ({ input }) => { - const { sessions } = await terminal.management.listSessions(); + const { sessions } = await defaultTerminal.management.listSessions(); const toKill = sessions.filter( (session) => session.workspaceId === input.workspaceId, ); @@ -362,7 +382,7 @@ export const createTerminalRouter = () => { if (toKill.length > 0) { const paneIds = toKill.map((session) => session.sessionId); const results = await Promise.allSettled( - paneIds.map((paneId) => terminal.kill({ paneId })), + paneIds.map((paneId) => defaultTerminal.kill({ paneId })), ); for (const [index, result] of results.entries()) { if (result.status === "rejected") { @@ -383,7 +403,7 @@ export const createTerminalRouter = () => { }), clearTerminalHistory: publicProcedure.mutation(async () => { - await terminal.management.resetHistoryPersistence(); + await defaultTerminal.management.resetHistoryPersistence(); return { success: true }; }), @@ -403,12 +423,14 @@ export const createTerminalRouter = () => { ); for (const session of sessions) { - void terminal.kill({ paneId: session.sessionId }).catch((error) => { - console.warn( - "[restartDaemon] Failed to mark session killed:", - error, - ); - }); + void defaultTerminal + .kill({ paneId: session.sessionId }) + .catch((error) => { + console.warn( + "[restartDaemon] Failed to mark session killed:", + error, + ); + }); } await client.shutdownIfRunning({ killSessions: true }); @@ -431,8 +453,14 @@ export const createTerminalRouter = () => { }), getSession: publicProcedure - .input(z.string()) - .query(async ({ input: paneId }) => { + .input( + z.object({ + paneId: z.string(), + workspaceId: z.string().optional(), + }), + ) + .query(async ({ input: { paneId, workspaceId } }) => { + const terminal = getTerminalForWorkspace(workspaceId); return terminal.getSession(paneId); }), @@ -448,6 +476,10 @@ export const createTerminalRouter = () => { return null; } + if (workspace.type === "remote") { + return workspace.remotePath ?? null; + } + if (!workspace.worktreeId) { return null; } @@ -461,8 +493,14 @@ export const createTerminalRouter = () => { }), stream: publicProcedure - .input(z.string()) - .subscription(({ input: paneId }) => { + .input( + z.object({ + paneId: z.string(), + workspaceId: z.string().optional(), + }), + ) + .subscription(({ input: { paneId, workspaceId } }) => { + const terminal = getTerminalForWorkspace(workspaceId); return observable< | { type: "data"; data: string } | { @@ -527,5 +565,66 @@ export const createTerminalRouter = () => { }; }); }), + + /** + * Upload a clipboard image to a remote workspace for TUI app consumption. + * The image is stored on the remote host and made available through + * clipboard proxy scripts (xclip, pngpaste, etc.) in ~/.superset/bin/. + */ + uploadClipboardImage: publicProcedure + .input( + z.object({ + workspaceId: SAFE_ID, + /** Base64-encoded image data */ + imageData: z.string(), + /** MIME type: image/png, image/jpeg, etc. */ + mimeType: z.string(), + }), + ) + .mutation(async ({ input }) => { + const { workspaceId, imageData, mimeType } = input; + + const MAX_IMAGE_SIZE = 10 * 1024 * 1024; // 10 MB + const estimatedSize = imageData.length * 0.75; + if (estimatedSize > MAX_IMAGE_SIZE) { + throw new TRPCError({ + code: "PAYLOAD_TOO_LARGE", + message: "Image too large (max 10 MB)", + }); + } + + const workspace = localDb + .select() + .from(workspaces) + .where(eq(workspaces.id, workspaceId)) + .get(); + + if (!workspace || workspace.type !== "remote") { + throw new TRPCError({ + code: "BAD_REQUEST", + message: + "Clipboard image upload is only supported for remote workspaces", + }); + } + + const runtime = registry.getForWorkspaceId(workspaceId); + + if (!("clipboard" in runtime)) { + throw new TRPCError({ + code: "INTERNAL_SERVER_ERROR", + message: "Remote runtime does not support clipboard operations", + }); + } + + const remoteRuntime = + runtime as import("main/lib/workspace-runtime/remote-ssh").RemoteSSHWorkspaceRuntime; + const imageBuffer = Buffer.from(imageData, "base64"); + const remotePath = await remoteRuntime.clipboard.uploadImage( + imageBuffer, + mimeType, + ); + + return { remotePath }; + }), }); }; diff --git a/apps/desktop/src/lib/trpc/routers/workspaces/procedures/create.ts b/apps/desktop/src/lib/trpc/routers/workspaces/procedures/create.ts index 9b8b8d62651..53fd3cb194a 100644 --- a/apps/desktop/src/lib/trpc/routers/workspaces/procedures/create.ts +++ b/apps/desktop/src/lib/trpc/routers/workspaces/procedures/create.ts @@ -1,6 +1,12 @@ import { homedir } from "node:os"; import { join } from "node:path"; -import { projects, settings, workspaces, worktrees } from "@superset/local-db"; +import { + projects, + settings, + sshConnections, + workspaces, + worktrees, +} from "@superset/local-db"; import { and, eq, isNull, not } from "drizzle-orm"; import { track } from "main/lib/analytics"; import { localDb } from "main/lib/local-db"; @@ -926,6 +932,73 @@ export const createCreateProcedures = () => { }); }), + createRemoteWorkspace: publicProcedure + .input( + z.object({ + projectId: z.string(), + sshConnectionId: z.string(), + remotePath: z.string().min(1), + name: z.string().optional(), + }), + ) + .mutation(async ({ input }) => { + const project = localDb + .select() + .from(projects) + .where(eq(projects.id, input.projectId)) + .get(); + if (!project) { + throw new Error(`Project ${input.projectId} not found`); + } + + const sshConn = localDb + .select() + .from(sshConnections) + .where(eq(sshConnections.id, input.sshConnectionId)) + .get(); + if (!sshConn) { + throw new Error(`SSH connection ${input.sshConnectionId} not found`); + } + + const maxTabOrder = getMaxWorkspaceTabOrder(input.projectId); + const workspaceName = + input.name || + `${sshConn.name}:${input.remotePath.split("/").pop() || "remote"}`; + + const workspace = localDb + .insert(workspaces) + .values({ + projectId: input.projectId, + type: "remote", + branch: "remote", + name: workspaceName, + sshConnectionId: input.sshConnectionId, + remotePath: input.remotePath, + tabOrder: maxTabOrder + 1, + }) + .returning() + .get(); + + setLastActiveWorkspace(workspace.id); + activateProject(project); + + track("workspace_created", { + workspace_id: workspace.id, + project_id: project.id, + type: "remote", + ssh_host: sshConn.host, + }); + + return { + workspace, + initialCommands: null, + worktreePath: input.remotePath, + projectId: project.id, + isInitializing: false, + wasExisting: false, + }; + }), + importAllWorktrees: publicProcedure .input(z.object({ projectId: z.string() })) .mutation(async ({ input }) => { diff --git a/apps/desktop/src/lib/trpc/routers/workspaces/procedures/query.ts b/apps/desktop/src/lib/trpc/routers/workspaces/procedures/query.ts index 5ebf8b694ab..d9811e88301 100644 --- a/apps/desktop/src/lib/trpc/routers/workspaces/procedures/query.ts +++ b/apps/desktop/src/lib/trpc/routers/workspaces/procedures/query.ts @@ -1,4 +1,9 @@ -import { projects, workspaces, worktrees } from "@superset/local-db"; +import { + projects, + sshConnections, + workspaces, + worktrees, +} from "@superset/local-db"; import { TRPCError } from "@trpc/server"; import { eq, isNotNull, isNull } from "drizzle-orm"; import { localDb } from "main/lib/local-db"; @@ -63,9 +68,17 @@ export const createQueryProcedures = () => { .get() : null; + const sshConnection = workspace.sshConnectionId + ? localDb + .select() + .from(sshConnections) + .where(eq(sshConnections.id, workspace.sshConnectionId)) + .get() + : null; + return { ...workspace, - type: workspace.type as "worktree" | "branch", + type: workspace.type as "worktree" | "branch" | "remote", worktreePath: getWorkspacePath(workspace) ?? "", project: project ? { @@ -81,6 +94,16 @@ export const createQueryProcedures = () => { gitStatus: worktree.gitStatus ?? null, } : null, + sshConnection: sshConnection + ? { + id: sshConnection.id, + name: sshConnection.name, + host: sshConnection.host, + port: sshConnection.port, + username: sshConnection.username, + connectionStatus: sshConnection.connectionStatus, + } + : null, }; }), @@ -105,6 +128,9 @@ export const createQueryProcedures = () => { allWorktrees.map((wt) => [wt.id, wt.path]), ); + const allSshConns = localDb.select().from(sshConnections).all(); + const sshConnectionMap = new Map(allSshConns.map((c) => [c.id, c])); + const groupsMap = new Map< string, { @@ -117,13 +143,14 @@ export const createQueryProcedures = () => { mainRepoPath: string; hideImage: boolean; iconUrl: string | null; + projectType: string; }; workspaces: Array<{ id: string; projectId: string; worktreeId: string | null; worktreePath: string; - type: "worktree" | "branch"; + type: "worktree" | "branch" | "remote"; branch: string; name: string; tabOrder: number; @@ -132,6 +159,10 @@ export const createQueryProcedures = () => { lastOpenedAt: number; isUnread: boolean; isUnnamed: boolean; + sshConnectionId: string | null; + remotePath: string | null; + sshHost: string | null; + sshUsername: string | null; }>; } >(); @@ -148,6 +179,7 @@ export const createQueryProcedures = () => { mainRepoPath: project.mainRepoPath, hideImage: project.hideImage ?? false, iconUrl: project.iconUrl ?? null, + projectType: project.projectType ?? "local", }, workspaces: [], }); @@ -168,14 +200,24 @@ export const createQueryProcedures = () => { worktreePath = worktreePathMap.get(workspace.worktreeId) ?? ""; } else if (workspace.type === "branch") { worktreePath = group.project.mainRepoPath; + } else if (workspace.type === "remote") { + worktreePath = workspace.remotePath ?? ""; } + const sshConn = workspace.sshConnectionId + ? sshConnectionMap.get(workspace.sshConnectionId) + : null; + group.workspaces.push({ ...workspace, - type: workspace.type as "worktree" | "branch", + type: workspace.type as "worktree" | "branch" | "remote", worktreePath, isUnread: workspace.isUnread ?? false, isUnnamed: workspace.isUnnamed ?? false, + sshConnectionId: workspace.sshConnectionId ?? null, + remotePath: workspace.remotePath ?? null, + sshHost: sshConn?.host ?? null, + sshUsername: sshConn?.username ?? null, }); } } diff --git a/apps/desktop/src/lib/trpc/routers/workspaces/utils/worktree.ts b/apps/desktop/src/lib/trpc/routers/workspaces/utils/worktree.ts index 0ecccdc326b..26f421480f3 100644 --- a/apps/desktop/src/lib/trpc/routers/workspaces/utils/worktree.ts +++ b/apps/desktop/src/lib/trpc/routers/workspaces/utils/worktree.ts @@ -18,6 +18,7 @@ export function getWorktreePath(worktreeId: string): string | undefined { * Gets the working directory path for a workspace. * For worktree workspaces: returns the worktree path * For branch workspaces: returns the main repo path + * For remote workspaces: returns the remote path */ export function getWorkspacePath(workspace: SelectWorkspace): string | null { if (workspace.type === "branch") { @@ -29,6 +30,10 @@ export function getWorkspacePath(workspace: SelectWorkspace): string | null { return project?.mainRepoPath ?? null; } + if (workspace.type === "remote") { + return workspace.remotePath ?? null; + } + // For worktree type, use worktree path if (workspace.worktreeId) { const worktree = localDb diff --git a/apps/desktop/src/main/lib/workspace-runtime/index.ts b/apps/desktop/src/main/lib/workspace-runtime/index.ts index 1b7a962fe73..17a5392bc44 100644 --- a/apps/desktop/src/main/lib/workspace-runtime/index.ts +++ b/apps/desktop/src/main/lib/workspace-runtime/index.ts @@ -18,6 +18,7 @@ export { getWorkspaceRuntimeRegistry, resetWorkspaceRuntimeRegistry, } from "./registry"; +export { RemoteSSHWorkspaceRuntime } from "./remote-ssh"; export type { TerminalCapabilities, TerminalEventSource, diff --git a/apps/desktop/src/main/lib/workspace-runtime/registry.ts b/apps/desktop/src/main/lib/workspace-runtime/registry.ts index c39b7850c2c..ad4759b3411 100644 --- a/apps/desktop/src/main/lib/workspace-runtime/registry.ts +++ b/apps/desktop/src/main/lib/workspace-runtime/registry.ts @@ -4,16 +4,18 @@ * Process-scoped registry for workspace runtime providers. * The registry is cached for the lifetime of the process. * - * Current behavior: - * - All workspaces use the LocalWorkspaceRuntime - * - The runtime is selected once based on settings (requires restart to change) - * - * Future behavior (cloud readiness): - * - Per-workspace selection based on workspace metadata (cloudWorkspaceId, etc.) - * - Local + cloud workspaces can coexist + * Behavior: + * - Local workspaces use LocalWorkspaceRuntime + * - Remote (SSH) workspaces use RemoteSSHWorkspaceRuntime + * - Per-workspace selection based on workspace metadata (type + sshConnectionId) */ +import { sshConnections, workspaces } from "@superset/local-db"; +import { eq } from "drizzle-orm"; +import { localDb } from "main/lib/local-db"; import { LocalWorkspaceRuntime } from "./local"; +import { RemoteSSHWorkspaceRuntime } from "./remote-ssh"; +import { getPoolKey } from "./remote-ssh/types"; import type { WorkspaceRuntime, WorkspaceRuntimeRegistry } from "./types"; // ============================================================================= @@ -23,29 +25,56 @@ import type { WorkspaceRuntime, WorkspaceRuntimeRegistry } from "./types"; /** * Default registry implementation. * - * Currently returns the same LocalWorkspaceRuntime for all workspaces. - * The interface supports per-workspace selection for future cloud work. + * Routes workspaces to the correct runtime: + * - type="remote" + sshConnectionId → RemoteSSHWorkspaceRuntime + * - Otherwise → LocalWorkspaceRuntime + * + * Remote runtimes are cached by connection pool key (user@host:port). */ class DefaultWorkspaceRuntimeRegistry implements WorkspaceRuntimeRegistry { private localRuntime: LocalWorkspaceRuntime | null = null; + private remoteRuntimes = new Map(); /** * Get the runtime for a specific workspace. * - * Currently always returns the local runtime. - * Future: will check workspace metadata to select local vs cloud. + * Checks workspace metadata to select local vs remote SSH runtime. */ - getForWorkspaceId(_workspaceId: string): WorkspaceRuntime { - // Currently all workspaces use the local runtime - // Future: check workspace metadata for cloudWorkspaceId to select cloud runtime + getForWorkspaceId(workspaceId: string): WorkspaceRuntime { + try { + const workspace = localDb + .select() + .from(workspaces) + .where(eq(workspaces.id, workspaceId)) + .get(); + + if (workspace?.type === "remote" && workspace.sshConnectionId) { + const sshConn = localDb + .select() + .from(sshConnections) + .where(eq(sshConnections.id, workspace.sshConnectionId)) + .get(); + + if (sshConn) { + return this.getOrCreateRemoteRuntime({ + id: sshConn.id, + host: sshConn.host, + port: sshConn.port, + username: sshConn.username, + identityFile: sshConn.privateKeyPath ?? undefined, + useAgent: sshConn.authMethod === "ssh-agent", + }); + } + } + } catch { + // Database not ready or workspace not found — fall through to default + } + return this.getDefault(); } /** * Get the default runtime (for global/legacy endpoints). - * - * Returns the local runtime, lazily initialized. - * The runtime instance is cached for the lifetime of the process. */ getDefault(): WorkspaceRuntime { if (!this.localRuntime) { @@ -53,6 +82,32 @@ class DefaultWorkspaceRuntimeRegistry implements WorkspaceRuntimeRegistry { } return this.localRuntime; } + + /** + * Get or create a cached remote SSH runtime for a given host config. + */ + private getOrCreateRemoteRuntime(config: { + id: string; + host: string; + port: number; + username: string; + identityFile?: string; + useAgent?: boolean; + }): RemoteSSHWorkspaceRuntime { + const key = getPoolKey(config); + const existing = this.remoteRuntimes.get(key); + if (existing) return existing; + + const runtime = new RemoteSSHWorkspaceRuntime(config); + this.remoteRuntimes.set(key, runtime); + + // Lazily connect — don't block registry lookup + void runtime.ensureConnected().catch((err) => { + console.error(`[Registry] Failed to connect SSH runtime ${key}:`, err); + }); + + return runtime; + } } // ============================================================================= @@ -66,11 +121,6 @@ let registryInstance: WorkspaceRuntimeRegistry | null = null; * * The registry is process-scoped and cached. Callers should capture it once * (e.g., when creating a tRPC router) and use it for the lifetime of the router. - * - * This design allows: - * 1. Stable runtime instances (no re-creation on each call) - * 2. Consistent event wiring (same backend for all listeners) - * 3. Future per-workspace selection (local vs cloud) */ export function getWorkspaceRuntimeRegistry(): WorkspaceRuntimeRegistry { if (!registryInstance) { diff --git a/apps/desktop/src/main/lib/workspace-runtime/remote-ssh/clipboard-service.ts b/apps/desktop/src/main/lib/workspace-runtime/remote-ssh/clipboard-service.ts new file mode 100644 index 00000000000..2f0b18fb955 --- /dev/null +++ b/apps/desktop/src/main/lib/workspace-runtime/remote-ssh/clipboard-service.ts @@ -0,0 +1,201 @@ +/** + * Remote Clipboard Service + * + * Bridges the local clipboard to remote SSH hosts for TUI apps. + * - Uploads images via SFTP to ~/.superset/clipboard/ + * - Deploys clipboard proxy scripts to ~/.superset/bin/ + * - Proxy scripts intercept xclip/pngpaste/xsel/wl-paste reads + * and serve the most recently uploaded image. + */ + +import type { SSHConnection } from "./connection"; +import type { SFTPService } from "./sftp-service"; + +const CLIPBOARD_DIR = ".superset/clipboard"; +const BIN_DIR = ".superset/bin"; +const MAX_CLIPBOARD_FILES = 5; +const PROXY_MARKER = "# superset-clipboard-proxy v1"; + +export class RemoteClipboardService { + private connection: SSHConnection; + private sftp: SFTPService; + private proxyDeployed = false; + private _homeDir: string | null = null; + + constructor(connection: SSHConnection, sftp: SFTPService) { + this.connection = connection; + this.sftp = sftp; + } + + setConnection(connection: SSHConnection): void { + this.connection = connection; + this.proxyDeployed = false; + } + + /** + * Upload an image to the remote clipboard directory. + * Returns the absolute path of the uploaded file. + */ + async uploadImage(imageBuffer: Buffer, mimeType: string): Promise { + const homeDir = await this.getHomeDir(); + const clipboardDir = `${homeDir}/${CLIPBOARD_DIR}`; + const ext = mimeType === "image/jpeg" ? "jpg" : "png"; + const filename = `clipboard-${Date.now()}.${ext}`; + const remotePath = `${clipboardDir}/${filename}`; + + // Ensure directory exists + await this.connection.exec(`mkdir -p '${clipboardDir}'`); + + // Write the image file + await this.sftp.writeFile(remotePath, imageBuffer); + + // Update the "latest" symlink so proxy scripts always serve the newest image + await this.connection.exec( + `ln -sf '${remotePath}' '${clipboardDir}/latest'`, + ); + + // Cleanup old files in the background + void this.cleanupOldFiles(clipboardDir).catch(() => {}); + + return remotePath; + } + + /** + * Deploy clipboard proxy scripts to ~/.superset/bin/. + * Idempotent: skips files that already have the proxy marker. + */ + async ensureProxyScripts(): Promise { + if (this.proxyDeployed) return; + + const homeDir = await this.getHomeDir(); + const binDir = `${homeDir}/${BIN_DIR}`; + const clipboardDir = `${homeDir}/${CLIPBOARD_DIR}`; + + await this.connection.exec(`mkdir -p '${binDir}' '${clipboardDir}'`); + + const scripts = buildProxyScripts(clipboardDir); + + for (const [name, content] of Object.entries(scripts)) { + const scriptPath = `${binDir}/${name}`; + + // Check if our proxy is already installed + const check = await this.connection + .exec(`head -2 '${scriptPath}' 2>/dev/null`) + .catch(() => ({ stdout: "", stderr: "", code: 1 })); + + if (check.stdout.includes(PROXY_MARKER)) continue; + + await this.sftp.writeFile(scriptPath, content); + await this.connection.exec(`chmod +x '${scriptPath}'`); + } + + this.proxyDeployed = true; + } + + private async cleanupOldFiles(clipboardDir: string): Promise { + await this.connection.exec( + `cd '${clipboardDir}' && ls -t clipboard-* 2>/dev/null | tail -n +${MAX_CLIPBOARD_FILES + 1} | xargs rm -f 2>/dev/null`, + ); + } + + private async getHomeDir(): Promise { + if (this._homeDir) return this._homeDir; + const result = await this.connection.exec("echo $HOME"); + this._homeDir = result.stdout.trim(); + return this._homeDir; + } +} + +// --------------------------------------------------------------------------- +// Proxy script generators +// --------------------------------------------------------------------------- + +function buildProxyScripts(clipboardDir: string): Record { + // Helper to generate the "find real binary" logic, skipping our bin dir + const findReal = (bin: string) => ` +_real="" +IFS=: read -ra _path_parts <<< "$PATH" +for _dir in "\${_path_parts[@]}"; do + [[ -z "$_dir" ]] && continue + case "$_dir" in */.superset/bin) continue ;; esac + if [[ -x "$_dir/${bin}" ]]; then + _real="$_dir/${bin}" + break + fi +done`; + + const xclip = `#!/bin/bash +${PROXY_MARKER} +# Proxy xclip: serve Superset clipboard images, forward everything else. + +_output=false _target="" +_args=("$@") + +while [[ $# -gt 0 ]]; do + case "$1" in + -o) _output=true; shift ;; + -t|-target) _target="$2"; shift 2 ;; + *) shift ;; + esac +done + +if [[ "$_output" == true ]] && [[ "$_target" == image/* ]]; then + _f="${clipboardDir}/latest" + [[ -f "$_f" ]] && { cat "$_f"; exit 0; } +fi +${findReal("xclip")} +[[ -n "$_real" ]] && exec "$_real" "\${_args[@]}" +exit 1 +`; + + const pngpaste = `#!/bin/bash +${PROXY_MARKER} +# Proxy pngpaste: serve Superset clipboard images. + +_out="\${1:--}" +_f="${clipboardDir}/latest" + +if [[ -f "$_f" ]]; then + if [[ "$_out" == "-" ]]; then cat "$_f"; else cp "$_f" "$_out"; fi + exit 0 +fi +${findReal("pngpaste")} +[[ -n "$_real" ]] && exec "$_real" "$@" +exit 1 +`; + + const xsel = `#!/bin/bash +${PROXY_MARKER} +# Proxy xsel: serve Superset clipboard images. + +_args=("$@") +if [[ "$*" == *"--clipboard"* ]] && [[ "$*" == *"--output"* ]]; then + _f="${clipboardDir}/latest" + [[ -f "$_f" ]] && { cat "$_f"; exit 0; } +fi +${findReal("xsel")} +[[ -n "$_real" ]] && exec "$_real" "\${_args[@]}" +exit 1 +`; + + const wlPaste = `#!/bin/bash +${PROXY_MARKER} +# Proxy wl-paste: serve Superset clipboard images. + +_args=("$@") +if [[ "$*" == *image* ]]; then + _f="${clipboardDir}/latest" + [[ -f "$_f" ]] && { cat "$_f"; exit 0; } +fi +${findReal("wl-paste")} +[[ -n "$_real" ]] && exec "$_real" "\${_args[@]}" +exit 1 +`; + + return { + xclip, + pngpaste, + xsel, + "wl-paste": wlPaste, + }; +} diff --git a/apps/desktop/src/renderer/components/NewWorkspaceModal/NewWorkspaceModal.tsx b/apps/desktop/src/renderer/components/NewWorkspaceModal/NewWorkspaceModal.tsx index 5f7078199b9..273db064950 100644 --- a/apps/desktop/src/renderer/components/NewWorkspaceModal/NewWorkspaceModal.tsx +++ b/apps/desktop/src/renderer/components/NewWorkspaceModal/NewWorkspaceModal.tsx @@ -25,7 +25,15 @@ import { DropdownMenuTrigger, } from "@superset/ui/dropdown-menu"; import { Input } from "@superset/ui/input"; +import { Label } from "@superset/ui/label"; import { Popover, PopoverContent, PopoverTrigger } from "@superset/ui/popover"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@superset/ui/select"; import { toast } from "@superset/ui/sonner"; import { useNavigate } from "@tanstack/react-router"; import { useEffect, useMemo, useRef, useState } from "react"; @@ -36,7 +44,7 @@ import { HiChevronUpDown, HiOutlinePencil, } from "react-icons/hi2"; -import { LuFolderOpen } from "react-icons/lu"; +import { LuArrowLeft, LuFolderOpen, LuLoader, LuServer } from "react-icons/lu"; import { electronTrpc } from "renderer/lib/electron-trpc"; import { formatRelativeTime } from "renderer/lib/formatRelativeTime"; import { @@ -44,6 +52,7 @@ import { useOpenNew, } from "renderer/react-query/projects"; import { useCreateWorkspace } from "renderer/react-query/workspaces"; +import { navigateToWorkspace } from "renderer/routes/_authenticated/_dashboard/utils/workspace-navigation"; import { useCloseNewWorkspaceModal, useNewWorkspaceModalOpen, @@ -60,7 +69,7 @@ function generateSlugFromTitle(title: string): string { return sanitizeSegment(title); } -type Mode = "existing" | "new" | "cloud"; +type Mode = "existing" | "new" | "cloud" | "remote"; export function NewWorkspaceModal() { const navigate = useNavigate(); @@ -78,6 +87,7 @@ export function NewWorkspaceModal() { const [baseBranchOpen, setBaseBranchOpen] = useState(false); const [branchSearch, setBranchSearch] = useState(""); const [showAdvanced, setShowAdvanced] = useState(false); + const [showSshForm, setShowSshForm] = useState(false); const titleInputRef = useRef(null); const { data: recentProjects = [] } = @@ -140,6 +150,13 @@ export function NewWorkspaceModal() { setBaseBranch(null); }, [selectedProjectId]); + // Auto-select "remote" mode when a remote project is selected + useEffect(() => { + if (project?.projectType === "remote") { + setMode("remote"); + } + }, [project?.projectType]); + const branchSlug = branchNameEdited ? sanitizeBranchName(branchName) : generateSlugFromTitle(title); @@ -160,6 +177,7 @@ export function NewWorkspaceModal() { setBaseBranch(null); setBranchSearch(""); setShowAdvanced(false); + setShowSshForm(false); }; useEffect(() => { @@ -313,6 +331,15 @@ export function NewWorkspaceModal() { Import repo + { + setSelectedProjectId(null); + setShowSshForm(true); + }} + > + + Add SSH Remote + @@ -321,38 +348,53 @@ export function NewWorkspaceModal() { <>
+ {project?.projectType !== "remote" && ( + <> + + + + )} -
@@ -543,18 +585,279 @@ export function NewWorkspaceModal() {

Coming soon

)} + {mode === "remote" && ( + + )} )} {!selectedProjectId && (
-
- Select a project to get started -
+ {showSshForm ? ( + setShowSshForm(false)} + /> + ) : ( +
+ Select a project to get started +
+ )}
)} ); } + +function RemoteWorkspaceForm({ + projectId, + onSuccess, +}: { + projectId: string; + onSuccess: () => void; +}) { + const navigate = useNavigate(); + const [sshConnectionId, setSshConnectionId] = useState(""); + const [remotePath, setRemotePath] = useState(""); + const [workspaceName, setWorkspaceName] = useState(""); + + const { data: connections = [] } = + electronTrpc.sshConnections.list.useQuery(); + + const createRemote = + electronTrpc.workspaces.createRemoteWorkspace.useMutation({ + onSuccess: (data) => { + toast.success("Remote workspace created"); + onSuccess(); + navigateToWorkspace(data.workspace.id, navigate, { replace: true }); + }, + onError: (err) => { + toast.error(err.message); + }, + }); + + const handleCreate = () => { + if (!sshConnectionId || !remotePath.trim()) return; + createRemote.mutate({ + projectId, + sshConnectionId, + remotePath: remotePath.trim(), + name: workspaceName.trim() || undefined, + }); + }; + + if (connections.length === 0) { + return ( +
+ +

No SSH connections

+

+ Configure an SSH connection first. +

+ +
+ ); + } + + return ( +
+
+ + +
+ +
+ + setRemotePath(e.target.value)} + /> +
+ +
+ + setWorkspaceName(e.target.value)} + /> +
+ + +
+ ); +} + +function SshRemoteProjectForm({ + onSuccess, + onBack, +}: { + onSuccess: () => void; + onBack: () => void; +}) { + const navigate = useNavigate(); + const [sshConnectionId, setSshConnectionId] = useState(""); + const [remotePath, setRemotePath] = useState(""); + const [projectName, setProjectName] = useState(""); + + const { data: connections = [] } = + electronTrpc.sshConnections.list.useQuery(); + const utils = electronTrpc.useUtils(); + + const createRemote = electronTrpc.projects.createRemote.useMutation({ + onSuccess: (data) => { + toast.success("Remote project created"); + utils.projects.getRecents.invalidate(); + utils.workspaces.getAllGrouped.invalidate(); + onSuccess(); + navigateToWorkspace(data.workspace.id, navigate, { replace: true }); + }, + onError: (err) => { + toast.error(err.message); + }, + }); + + const handleCreate = () => { + if (!sshConnectionId || !remotePath.trim()) return; + createRemote.mutate({ + sshConnectionId, + remotePath: remotePath.trim(), + name: projectName.trim() || undefined, + }); + }; + + if (connections.length === 0) { + return ( +
+ +

No SSH connections

+

+ Configure an SSH connection first. +

+
+ + +
+
+ ); + } + + return ( +
+ + +
+ + +
+ +
+ + setRemotePath(e.target.value)} + /> +
+ +
+ + setProjectName(e.target.value)} + /> +
+ + +
+ ); +} diff --git a/apps/desktop/src/renderer/components/Paywall/usePaywall.ts b/apps/desktop/src/renderer/components/Paywall/usePaywall.ts index 8ec8ad48d76..661c40ccc57 100644 --- a/apps/desktop/src/renderer/components/Paywall/usePaywall.ts +++ b/apps/desktop/src/renderer/components/Paywall/usePaywall.ts @@ -1,3 +1,4 @@ +import { env } from "renderer/env.renderer"; import { authClient } from "renderer/lib/auth-client"; import type { GatedFeature } from "./constants"; import { paywall } from "./Paywall"; @@ -7,7 +8,9 @@ type UserPlan = "free" | "pro"; export function usePaywall() { const { data: session } = authClient.useSession(); - const userPlan: UserPlan = (session?.session?.plan as UserPlan) ?? "free"; + const userPlan: UserPlan = env.SKIP_ENV_VALIDATION + ? "pro" + : ((session?.session?.plan as UserPlan) ?? "free"); function hasAccess(feature: GatedFeature): boolean { void feature; diff --git a/apps/desktop/src/renderer/env.renderer.ts b/apps/desktop/src/renderer/env.renderer.ts index 404ce33ec36..4c56113ba43 100644 --- a/apps/desktop/src/renderer/env.renderer.ts +++ b/apps/desktop/src/renderer/env.renderer.ts @@ -46,9 +46,8 @@ const rawEnv = { SENTRY_DSN_DESKTOP: import.meta.env.SENTRY_DSN_DESKTOP as string | undefined, }; -// Only allow skipping validation in development (never in production) -const SKIP_ENV_VALIDATION = - process.env.NODE_ENV === "development" && !!process.env.SKIP_ENV_VALIDATION; +// Local-only mode: always skip env validation and auth +const SKIP_ENV_VALIDATION = true; export const env = { ...(SKIP_ENV_VALIDATION diff --git a/apps/desktop/src/renderer/hooks/useVersionCheck/useVersionCheck.ts b/apps/desktop/src/renderer/hooks/useVersionCheck/useVersionCheck.ts index 4d055235c61..372cacdf1cc 100644 --- a/apps/desktop/src/renderer/hooks/useVersionCheck/useVersionCheck.ts +++ b/apps/desktop/src/renderer/hooks/useVersionCheck/useVersionCheck.ts @@ -26,6 +26,16 @@ export function useVersionCheck(): UseVersionCheckResult { const hasVerified = useRef(false); const checkVersion = useCallback(async () => { + if (env.SKIP_ENV_VALIDATION) { + setState({ + isLoading: false, + isBlocked: false, + requirements: null, + error: null, + }); + return; + } + // Don't show loading state on re-checks (only on initial load) if (!hasVerified.current) { setState((prev) => ({ ...prev, isLoading: true })); diff --git a/apps/desktop/src/renderer/index.html b/apps/desktop/src/renderer/index.html index 1d79221b3bc..774abf9df2d 100644 --- a/apps/desktop/src/renderer/index.html +++ b/apps/desktop/src/renderer/index.html @@ -9,13 +9,13 @@ https://developer.mozilla.org/en-US/docs/Web/HTTP/CSP - default-src 'self': Only allow resources from same origin - - script-src 'self' 'wasm-unsafe-eval' https://*.posthog.com: Allow scripts from same origin + WebAssembly (for xterm ImageAddon) + PostHog + - script-src 'self' 'unsafe-eval' 'wasm-unsafe-eval' https://*.posthog.com: Allow scripts from same origin + eval (needed by dependencies) + WebAssembly (for xterm ImageAddon) + PostHog - style-src 'self' 'unsafe-inline': Allow styles from same origin + inline (needed for CSS-in-JS) - connect-src 'self' ws: wss: %NEXT_PUBLIC_API_URL% %NEXT_PUBLIC_ELECTRIC_URL% %NEXT_PUBLIC_STREAMS_URL% https://*.posthog.com https://*.sentry.io sentry-ipc:: Allow WebSocket + API + Electric proxy + Streams server + PostHog + Sentry - img-src 'self' data: https: Allow images from same origin + data URIs + any HTTPS source (needed for favicons from arbitrary sites in browser history) - font-src 'self': Allow fonts from same origin --> - + diff --git a/apps/desktop/src/renderer/lib/api-trpc-client.ts b/apps/desktop/src/renderer/lib/api-trpc-client.ts index 261da0b9af0..729db44a81e 100644 --- a/apps/desktop/src/renderer/lib/api-trpc-client.ts +++ b/apps/desktop/src/renderer/lib/api-trpc-client.ts @@ -4,6 +4,22 @@ import { env } from "renderer/env.renderer"; import superjson from "superjson"; import { getAuthToken } from "./auth-client"; +/** + * In local-only mode, return a valid tRPC batch response + * so no real HTTP requests are made. + */ +const mockTrpcFetch: typeof fetch = async (_url, init) => { + const body = typeof init?.body === "string" ? JSON.parse(init.body) : {}; + const batchSize = Object.keys(body).length || 1; + const results = Array.from({ length: batchSize }, () => ({ + result: { data: { json: null } }, + })); + return new Response(JSON.stringify(results), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); +}; + /** * HTTP tRPC client for calling the API server. * Uses bearer token authentication like the auth client. @@ -14,6 +30,7 @@ export const apiTrpcClient = createTRPCProxyClient({ httpBatchLink({ url: `${env.NEXT_PUBLIC_API_URL}/api/trpc`, transformer: superjson, + ...(env.SKIP_ENV_VALIDATION ? { fetch: mockTrpcFetch } : {}), headers: () => { const token = getAuthToken(); if (token) { diff --git a/apps/desktop/src/renderer/lib/auth-client.ts b/apps/desktop/src/renderer/lib/auth-client.ts index e898d589360..0b9c9db5061 100644 --- a/apps/desktop/src/renderer/lib/auth-client.ts +++ b/apps/desktop/src/renderer/lib/auth-client.ts @@ -7,6 +7,12 @@ import { } from "better-auth/client/plugins"; import { createAuthClient } from "better-auth/react"; import { env } from "renderer/env.renderer"; +import { + MOCK_ORG_ID, + MOCK_USER_EMAIL, + MOCK_USER_ID, + MOCK_USER_NAME, +} from "shared/constants"; let authToken: string | null = null; @@ -18,6 +24,35 @@ export function getAuthToken(): string | null { return authToken; } +function createMockFetch() { + const mockSession = { + session: { + id: "mock-session-id", + userId: MOCK_USER_ID, + token: "mock-token", + expiresAt: new Date(Date.now() + 86400000 * 30).toISOString(), + activeOrganizationId: MOCK_ORG_ID, + organizationIds: [MOCK_ORG_ID], + plan: "pro", + }, + user: { + id: MOCK_USER_ID, + email: MOCK_USER_EMAIL, + name: MOCK_USER_NAME, + image: null, + emailVerified: true, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }, + }; + + return async () => + new Response(JSON.stringify(mockSession), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); +} + /** * Better Auth client for Electron desktop app. * @@ -34,6 +69,7 @@ export const authClient = createAuthClient({ ], fetchOptions: { credentials: "include", + ...(env.SKIP_ENV_VALIDATION ? { customFetchImpl: createMockFetch() } : {}), onRequest: async (context) => { const token = getAuthToken(); if (token) { diff --git a/apps/desktop/src/renderer/providers/AuthProvider/AuthProvider.tsx b/apps/desktop/src/renderer/providers/AuthProvider/AuthProvider.tsx index 6f288a0588c..b158d1a1156 100644 --- a/apps/desktop/src/renderer/providers/AuthProvider/AuthProvider.tsx +++ b/apps/desktop/src/renderer/providers/AuthProvider/AuthProvider.tsx @@ -1,9 +1,19 @@ import { Spinner } from "@superset/ui/spinner"; import { type ReactNode, useEffect, useState } from "react"; +import { env } from "renderer/env.renderer"; import { authClient, setAuthToken } from "renderer/lib/auth-client"; import { electronTrpc } from "../../lib/electron-trpc"; export function AuthProvider({ children }: { children: ReactNode }) { + // Local-only mode: skip all token hydration + if (env.SKIP_ENV_VALIDATION) { + return <>{children}; + } + + return {children}; +} + +function AuthProviderInner({ children }: { children: ReactNode }) { const [isHydrated, setIsHydrated] = useState(false); const { refetch: refetchSession } = authClient.useSession(); diff --git a/apps/desktop/src/renderer/routes/_authenticated/providers/CollectionsProvider/CollectionsProvider.tsx b/apps/desktop/src/renderer/routes/_authenticated/providers/CollectionsProvider/CollectionsProvider.tsx index a1d9d39aca4..2d3f7166273 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/providers/CollectionsProvider/CollectionsProvider.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/providers/CollectionsProvider/CollectionsProvider.tsx @@ -45,8 +45,10 @@ export function CollectionsProvider({ children }: { children: ReactNode }) { // Preload collections for all orgs the user belongs to. // Collections are lazy — they don't sync until subscribed or preloaded. // This starts Electric subscriptions eagerly so data is ready on org switch. + // Skip preloading in local-only mode to avoid unnecessary network requests. const organizationIds = session?.session?.organizationIds; useEffect(() => { + if (env.SKIP_ENV_VALIDATION) return; if (!organizationIds) return; for (const orgId of organizationIds) { preloadCollections(orgId); diff --git a/apps/desktop/src/renderer/routes/_authenticated/providers/CollectionsProvider/collections.ts b/apps/desktop/src/renderer/routes/_authenticated/providers/CollectionsProvider/collections.ts index 0c3d9776fe8..a523df3f7b8 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/providers/CollectionsProvider/collections.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/providers/CollectionsProvider/collections.ts @@ -15,7 +15,10 @@ import type { import type { AppRouter } from "@superset/trpc"; import { electricCollectionOptions } from "@tanstack/electric-db-collection"; import type { Collection } from "@tanstack/react-db"; -import { createCollection } from "@tanstack/react-db"; +import { + createCollection, + localOnlyCollectionOptions, +} from "@tanstack/react-db"; import { createTRPCProxyClient, httpBatchLink } from "@trpc/client"; import { env } from "renderer/env.renderer"; import { getAuthToken } from "renderer/lib/auth-client"; @@ -52,19 +55,21 @@ interface OrgCollections { // Per-org collections cache const collectionsCache = new Map(); -// Singleton API client with dynamic auth headers -const apiClient = createTRPCProxyClient({ - links: [ - httpBatchLink({ - url: `${env.NEXT_PUBLIC_API_URL}/api/trpc`, - headers: () => { - const token = getAuthToken(); - return token ? { Authorization: `Bearer ${token}` } : {}; - }, - transformer: superjson, - }), - ], -}); +// Singleton API client with dynamic auth headers (only used when not in local-only mode) +const apiClient = env.SKIP_ENV_VALIDATION + ? (null as unknown as ReturnType>) + : createTRPCProxyClient({ + links: [ + httpBatchLink({ + url: `${env.NEXT_PUBLIC_API_URL}/api/trpc`, + headers: () => { + const token = getAuthToken(); + return token ? { Authorization: `Bearer ${token}` } : {}; + }, + transformer: superjson, + }), + ], + }); const electricHeaders = { Authorization: () => { @@ -73,20 +78,68 @@ const electricHeaders = { }, }; -const organizationsCollection = createCollection( - electricCollectionOptions({ - id: "organizations", - shapeOptions: { - url: electricUrl, - params: { table: "auth.organizations" }, - headers: electricHeaders, - columnMapper, - }, - getKey: (item) => item.id, - }), -); +/** + * Create a local-only collection stub (no Electric sync, no network). + * Used in local-only mode when auth is bypassed. + */ +function createLocalStub(id: string): Collection { + return createCollection( + localOnlyCollectionOptions({ + id, + getKey: (item: T) => (item as Record).id as string, + }), + ); +} + +const organizationsCollection = env.SKIP_ENV_VALIDATION + ? createLocalStub("organizations") + : createCollection( + electricCollectionOptions({ + id: "organizations", + shapeOptions: { + url: electricUrl, + params: { table: "auth.organizations" }, + headers: electricHeaders, + columnMapper, + }, + getKey: (item) => item.id, + }), + ); + +function createLocalOrgCollections(organizationId: string): OrgCollections { + return { + tasks: createLocalStub(`tasks-${organizationId}`), + taskStatuses: createLocalStub( + `task_statuses-${organizationId}`, + ), + projects: createLocalStub(`projects-${organizationId}`), + members: createLocalStub(`members-${organizationId}`), + users: createLocalStub(`users-${organizationId}`), + invitations: createLocalStub( + `invitations-${organizationId}`, + ), + agentCommands: createLocalStub( + `agent_commands-${organizationId}`, + ), + devicePresence: createLocalStub( + `device_presence-${organizationId}`, + ), + integrationConnections: createLocalStub( + `integration_connections-${organizationId}`, + ), + subscriptions: createLocalStub( + `subscriptions-${organizationId}`, + ), + apiKeys: createLocalStub(`apikeys-${organizationId}`), + }; +} function createOrgCollections(organizationId: string): OrgCollections { + // Local-only mode: return empty local stubs (no Electric sync) + if (env.SKIP_ENV_VALIDATION) { + return createLocalOrgCollections(organizationId); + } + const tasks = createCollection( electricCollectionOptions({ id: `tasks-${organizationId}`, diff --git a/apps/desktop/src/renderer/routes/_authenticated/settings/components/SettingsSidebar/GeneralSettings.tsx b/apps/desktop/src/renderer/routes/_authenticated/settings/components/SettingsSidebar/GeneralSettings.tsx index f0523525060..09deb2b2cef 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/settings/components/SettingsSidebar/GeneralSettings.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/settings/components/SettingsSidebar/GeneralSettings.tsx @@ -12,7 +12,7 @@ import { HiOutlineSparkles, HiOutlineUser, } from "react-icons/hi2"; -import { LuKeyboard } from "react-icons/lu"; +import { LuKeyboard, LuServer } from "react-icons/lu"; import type { SettingsSection } from "renderer/stores/settings-state"; interface GeneralSettingsProps { @@ -30,7 +30,8 @@ type SettingsRoute = | "/settings/integrations" | "/settings/billing" | "/settings/devices" - | "/settings/api-keys"; + | "/settings/api-keys" + | "/settings/ssh-connections"; const GENERAL_SECTIONS: { id: SettingsRoute; @@ -104,6 +105,12 @@ const GENERAL_SECTIONS: { label: "API Keys", icon: , }, + { + id: "/settings/ssh-connections", + section: "ssh-connections", + label: "SSH Connections", + icon: , + }, ]; export function GeneralSettings({ matchCounts }: GeneralSettingsProps) { diff --git a/apps/desktop/src/renderer/routes/_authenticated/settings/ssh-connections/components/SshConnectionsSettings.tsx b/apps/desktop/src/renderer/routes/_authenticated/settings/ssh-connections/components/SshConnectionsSettings.tsx new file mode 100644 index 00000000000..0a78487cc97 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/settings/ssh-connections/components/SshConnectionsSettings.tsx @@ -0,0 +1,442 @@ +import { Badge } from "@superset/ui/badge"; +import { Button } from "@superset/ui/button"; +import { + Dialog, + DialogContent, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@superset/ui/dialog"; +import { Input } from "@superset/ui/input"; +import { Label } from "@superset/ui/label"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@superset/ui/select"; +import { toast } from "@superset/ui/sonner"; +import { cn } from "@superset/ui/utils"; +import { useState } from "react"; +import { LuLoader, LuPencil, LuPlus, LuServer, LuTrash2 } from "react-icons/lu"; +import { electronTrpc } from "renderer/lib/electron-trpc"; +import { SETTING_ITEM_ID } from "../../utils/settings-search"; +import type { SettingItemId } from "../../utils/settings-search/settings-search"; +import { isItemVisible } from "../../utils/settings-search/settings-search"; + +interface SshConnectionsSettingsProps { + visibleItems: SettingItemId[] | null; +} + +export function SshConnectionsSettings({ + visibleItems, +}: SshConnectionsSettingsProps) { + const [isDialogOpen, setIsDialogOpen] = useState(false); + const [editingId, setEditingId] = useState(null); + + const { data: connections = [], refetch } = + electronTrpc.sshConnections.list.useQuery(); + const { data: sshKeys = [] } = + electronTrpc.sshConnections.listSshKeys.useQuery(); + const { data: agentStatus } = + electronTrpc.sshConnections.checkSshAgent.useQuery(); + + const createMutation = electronTrpc.sshConnections.create.useMutation({ + onSuccess: () => { + refetch(); + toast.success("SSH connection created"); + }, + onError: (err) => toast.error(err.message), + }); + + const updateMutation = electronTrpc.sshConnections.update.useMutation({ + onSuccess: () => { + refetch(); + toast.success("SSH connection updated"); + }, + onError: (err) => toast.error(err.message), + }); + + const deleteMutation = electronTrpc.sshConnections.delete.useMutation({ + onSuccess: () => { + refetch(); + toast.success("SSH connection deleted"); + }, + onError: (err) => toast.error(err.message), + }); + + const testMutation = electronTrpc.sshConnections.testConnection.useMutation({ + onSuccess: (result) => { + refetch(); + if (result.success) { + toast.success("Connection successful"); + } else { + toast.error("Connection failed", { description: result.error }); + } + }, + onError: (err) => toast.error(err.message), + }); + + const browseMutation = + electronTrpc.sshConnections.browseKeyFile.useMutation(); + + const handleOpenCreate = () => { + setEditingId(null); + setIsDialogOpen(true); + }; + + const handleOpenEdit = (id: string) => { + setEditingId(id); + setIsDialogOpen(true); + }; + + const editingConnection = editingId + ? connections.find((c) => c.id === editingId) + : null; + + return ( +
+
+

SSH Connections

+

+ Manage SSH host configurations for remote workspaces. +

+
+ + {isItemVisible(SETTING_ITEM_ID.SSH_CONNECTIONS_LIST, visibleItems) && ( +
+ {connections.length === 0 ? ( +
+ +

No SSH connections

+

+ Add a connection to create remote workspaces. +

+
+ ) : ( +
+ {connections.map((conn) => { + const status = conn.connectionStatus as { + status: string; + error?: string; + } | null; + const statusValue = status?.status ?? "untested"; + + return ( +
+ +
+
+ + {conn.name} + + + {statusValue} + +
+

+ {conn.username}@{conn.host}:{conn.port} +

+
+
+ + + +
+
+ ); + })} +
+ )} +
+ )} + + {isItemVisible(SETTING_ITEM_ID.SSH_CONNECTIONS_ADD, visibleItems) && ( + + )} + + { + const result = await browseMutation.mutateAsync(); + return result.canceled ? null : result.path; + }} + onSave={async (values) => { + if (editingId) { + await updateMutation.mutateAsync({ id: editingId, ...values }); + } else { + await createMutation.mutateAsync(values); + } + setIsDialogOpen(false); + }} + isSaving={createMutation.isPending || updateMutation.isPending} + /> +
+ ); +} + +interface SshConnectionDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + connection?: { + name: string; + host: string; + port: number; + username: string; + authMethod: string; + privateKeyPath: string | null; + } | null; + sshKeys: Array<{ name: string; path: string }>; + agentAvailable: boolean; + onBrowseKey: () => Promise; + onSave: (values: { + name: string; + host: string; + port: number; + username: string; + authMethod: "key-file" | "ssh-agent"; + privateKeyPath?: string | null; + }) => Promise; + isSaving: boolean; +} + +function SshConnectionDialog({ + open, + onOpenChange, + connection, + sshKeys, + agentAvailable, + onBrowseKey, + onSave, + isSaving, +}: SshConnectionDialogProps) { + const [name, setName] = useState(""); + const [host, setHost] = useState(""); + const [port, setPort] = useState("22"); + const [username, setUsername] = useState(""); + const [authMethod, setAuthMethod] = useState<"key-file" | "ssh-agent">( + "key-file", + ); + const [privateKeyPath, setPrivateKeyPath] = useState(""); + + // Reset form when dialog opens + const handleOpenChange = (isOpen: boolean) => { + if (isOpen) { + if (connection) { + setName(connection.name); + setHost(connection.host); + setPort(String(connection.port)); + setUsername(connection.username); + setAuthMethod(connection.authMethod as "key-file" | "ssh-agent"); + setPrivateKeyPath(connection.privateKeyPath ?? ""); + } else { + setName(""); + setHost(""); + setPort("22"); + setUsername(""); + setAuthMethod("key-file"); + setPrivateKeyPath(""); + } + } + onOpenChange(isOpen); + }; + + const handleSave = async () => { + await onSave({ + name, + host, + port: Number.parseInt(port, 10) || 22, + username, + authMethod, + privateKeyPath: authMethod === "key-file" ? privateKeyPath : null, + }); + }; + + const handleBrowse = async () => { + const path = await onBrowseKey(); + if (path) setPrivateKeyPath(path); + }; + + const isValid = + name.trim() && + host.trim() && + username.trim() && + (authMethod === "ssh-agent" || privateKeyPath.trim()); + + return ( + + + + + {connection ? "Edit SSH Connection" : "Add SSH Connection"} + + + +
+
+ + setName(e.target.value)} + /> +
+ +
+
+ + setHost(e.target.value)} + /> +
+
+ + setPort(e.target.value)} + /> +
+
+ +
+ + setUsername(e.target.value)} + /> +
+ +
+ + +
+ + {authMethod === "key-file" && ( +
+ +
+ setPrivateKeyPath(e.target.value)} + /> + +
+ {sshKeys.length > 0 && !privateKeyPath && ( +
+ {sshKeys.map((key) => ( + + ))} +
+ )} +
+ )} +
+ + + + + +
+
+ ); +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/settings/ssh-connections/page.tsx b/apps/desktop/src/renderer/routes/_authenticated/settings/ssh-connections/page.tsx new file mode 100644 index 00000000000..aff680a3801 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/settings/ssh-connections/page.tsx @@ -0,0 +1,24 @@ +import { createFileRoute } from "@tanstack/react-router"; +import { useMemo } from "react"; +import { useSettingsSearchQuery } from "renderer/stores/settings-state"; +import { getMatchingItemsForSection } from "../utils/settings-search"; +import { SshConnectionsSettings } from "./components/SshConnectionsSettings"; + +export const Route = createFileRoute( + "/_authenticated/settings/ssh-connections/", +)({ + component: SshConnectionsSettingsPage, +}); + +function SshConnectionsSettingsPage() { + const searchQuery = useSettingsSearchQuery(); + + const visibleItems = useMemo(() => { + if (!searchQuery) return null; + return getMatchingItemsForSection(searchQuery, "ssh-connections").map( + (item) => item.id, + ); + }, [searchQuery]); + + return ; +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/settings/utils/settings-search/settings-search.ts b/apps/desktop/src/renderer/routes/_authenticated/settings/utils/settings-search/settings-search.ts index e0cdbdc742d..ff308bc8647 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/settings/utils/settings-search/settings-search.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/settings/utils/settings-search/settings-search.ts @@ -51,6 +51,10 @@ export const SETTING_ITEM_ID = { // API Keys API_KEYS_LIST: "api-keys-list", API_KEYS_GENERATE: "api-keys-generate", + + // SSH Connections + SSH_CONNECTIONS_LIST: "ssh-connections-list", + SSH_CONNECTIONS_ADD: "ssh-connections-add", } as const; export type SettingItemId = @@ -728,6 +732,42 @@ export const SETTINGS_ITEMS: SettingsItem[] = [ "claude code", ], }, + // SSH Connections + { + id: SETTING_ITEM_ID.SSH_CONNECTIONS_LIST, + section: "ssh-connections", + title: "SSH Connections", + description: "Manage SSH host configurations for remote workspaces", + keywords: [ + "ssh", + "remote", + "server", + "host", + "connection", + "key", + "identity", + "agent", + "port", + "username", + ], + }, + { + id: SETTING_ITEM_ID.SSH_CONNECTIONS_ADD, + section: "ssh-connections", + title: "Add SSH Connection", + description: "Configure a new SSH host for remote workspaces", + keywords: [ + "ssh", + "add", + "new", + "create", + "remote", + "server", + "host", + "connect", + "configure", + ], + }, ]; export function searchSettings(query: string): SettingsItem[] { diff --git a/apps/desktop/src/renderer/screens/main/components/StartView/index.tsx b/apps/desktop/src/renderer/screens/main/components/StartView/index.tsx index cc2af19ae9a..d936026f88c 100644 --- a/apps/desktop/src/renderer/screens/main/components/StartView/index.tsx +++ b/apps/desktop/src/renderer/screens/main/components/StartView/index.tsx @@ -1,14 +1,17 @@ import { Button } from "@superset/ui/button"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@superset/ui/tooltip"; import { cn } from "@superset/ui/utils"; import { useNavigate } from "@tanstack/react-router"; import { useCallback, useEffect, useState } from "react"; -import { LuFolderGit, LuFolderOpen, LuX } from "react-icons/lu"; +import { CiSettings } from "react-icons/ci"; +import { LuFolderGit, LuFolderOpen, LuServer, LuX } from "react-icons/lu"; import { processOpenNewResults, useOpenFromPath, useOpenNew, } from "renderer/react-query/projects"; import { SupersetLogo } from "renderer/routes/sign-in/components/SupersetLogo"; +import { useOpenNewWorkspaceModal } from "renderer/stores/new-workspace-modal"; import { CloneRepoDialog } from "./CloneRepoDialog"; import { InitGitDialog } from "./InitGitDialog"; @@ -27,6 +30,7 @@ export function StartView() { >(null); const [isCloneDialogOpen, setIsCloneDialogOpen] = useState(false); const [isDragOver, setIsDragOver] = useState(false); + const openWorkspaceModal = useOpenNewWorkspaceModal(); const isLoading = openNew.isPending || openFromPath.isPending; @@ -196,6 +200,26 @@ export function StartView() { return (
+ {/* Top bar with settings button */} +
+ + + + + + Settings + + +
+ {/* biome-ignore lint/a11y/noStaticElementInteractions: Drop zone for external files */}
Clone Repository +
diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/ProjectSection/ProjectSection.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/ProjectSection/ProjectSection.tsx index 6e754563a36..828fb61e9fd 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/ProjectSection/ProjectSection.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/ProjectSection/ProjectSection.tsx @@ -15,11 +15,15 @@ interface Workspace { id: string; projectId: string; worktreePath: string; - type: "worktree" | "branch"; + type: "worktree" | "branch" | "remote"; branch: string; name: string; tabOrder: number; isUnread: boolean; + sshConnectionId?: string | null; + remotePath?: string | null; + sshHost?: string | null; + sshUsername?: string | null; } interface ProjectSectionProps { @@ -175,6 +179,8 @@ export function ProjectSection({ index={wsIndex} shortcutIndex={shortcutBaseIndex + wsIndex} isCollapsed={isSidebarCollapsed} + sshHost={workspace.sshHost} + sshUsername={workspace.sshUsername} /> ))} @@ -233,6 +239,8 @@ export function ProjectSection({ isUnread={workspace.isUnread} index={wsIndex} shortcutIndex={shortcutBaseIndex + wsIndex} + sshHost={workspace.sshHost} + sshUsername={workspace.sshUsername} /> ))} diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceListItem/CollapsedWorkspaceItem.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceListItem/CollapsedWorkspaceItem.tsx index e9a2693cc50..fdb150d3c65 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceListItem/CollapsedWorkspaceItem.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceListItem/CollapsedWorkspaceItem.tsx @@ -24,7 +24,7 @@ interface CollapsedWorkspaceItemProps { id: string; name: string; branch: string; - type: "worktree" | "branch"; + type: "worktree" | "branch" | "remote"; isActive: boolean; isUnread: boolean; workspaceStatus: ActivePaneStatus | null; @@ -35,6 +35,8 @@ interface CollapsedWorkspaceItemProps { onClick: () => void; onDeleteClick: () => void; onCopyPath: () => void; + sshHost?: string | null; + sshUsername?: string | null; } export function CollapsedWorkspaceItem({ @@ -52,8 +54,11 @@ export function CollapsedWorkspaceItem({ onClick, onDeleteClick, onCopyPath, + sshHost, + sshUsername, }: CollapsedWorkspaceItemProps) { const isBranchWorkspace = type === "branch"; + const isRemoteWorkspace = type === "remote"; const collapsedButton = ( + )} + - return ( -
- -
-
- - {conn.name} - - + {connections.length === 0 ? ( + + + +

No SSH connections

+

+ Add a connection to create remote workspaces. +

+
+
+ ) : ( + + {connections.map((conn) => { + const status = conn.connectionStatus as { + status: string; + error?: string; + } | null; + const statusValue = status?.status ?? "untested"; + + return ( +
+ +
+
+ + {conn.name} + + + {statusValue.replace("-", " ")} + +
+

+ {conn.username}@{conn.host}:{conn.port} +

+
+
+ + +
-

- {conn.username}@{conn.host}:{conn.port} -

-
-
- - -
-
- ); - })} -
- )} -
- )} - - {isItemVisible(SETTING_ITEM_ID.SSH_CONNECTIONS_ADD, visibleItems) && ( - - )} + ); + })} + + )} + + )} + { @@ -116,6 +123,13 @@ export function WorkspaceSidebarFooter({ Clone repo + openWorkspaceModal()} + disabled={isLoading} + > + + SSH Remote + @@ -155,6 +169,13 @@ export function WorkspaceSidebarFooter({ Clone repo + openWorkspaceModal()} + disabled={isLoading} + > + + SSH Remote + diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/Terminal.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/Terminal.tsx index 01041334dd9..187d95c5dcc 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/Terminal.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/Terminal.tsx @@ -37,6 +37,14 @@ import { shellEscapePaths } from "./utils"; const stripLeadingEmoji = (text: string) => text.trim().replace(/^[\p{Emoji}\p{Symbol}]\s*/u, ""); +const isTerminalDaemonDisconnect = (message: string) => { + const normalized = message.toLowerCase(); + return ( + normalized.includes("terminal daemon") && + (normalized.includes("lost") || normalized.includes("disconnect")) + ); +}; + export const Terminal = ({ paneId, tabId, workspaceId }: TerminalProps) => { const pane = useTabsStore((s) => s.panes[paneId]); const paneInitialCommands = pane?.initialCommands; @@ -352,6 +360,49 @@ export const Terminal = ({ paneId, tabId, workspaceId }: TerminalProps) => { }, [fontSettings]); const terminalBg = terminalTheme?.background ?? getDefaultTerminalBg(); + const autoRetryAttemptsRef = useRef(0); + const autoRetryTimerRef = useRef | null>(null); + + useEffect(() => { + // Connection recovered: reset retry state. + if (!connectionError) { + autoRetryAttemptsRef.current = 0; + if (autoRetryTimerRef.current) { + clearTimeout(autoRetryTimerRef.current); + autoRetryTimerRef.current = null; + } + return; + } + + // Only auto-retry daemon disconnect overlays. + if (!isTerminalDaemonDisconnect(connectionError)) { + return; + } + + // Already scheduled a retry for the current error state. + if (autoRetryTimerRef.current) { + return; + } + + // Limit to 3 automatic retries; then keep manual Retry button. + if (autoRetryAttemptsRef.current >= 3) { + return; + } + + autoRetryTimerRef.current = setTimeout(() => { + autoRetryTimerRef.current = null; + autoRetryAttemptsRef.current += 1; + handleRetryConnection(); + }, 3000); + }, [connectionError, handleRetryConnection]); + + useEffect(() => { + return () => { + if (autoRetryTimerRef.current) { + clearTimeout(autoRetryTimerRef.current); + } + }; + }, []); const handleDragOver = (event: React.DragEvent) => { event.preventDefault(); diff --git a/apps/desktop/src/shared/constants.ts b/apps/desktop/src/shared/constants.ts index 25128bd421c..5b87096daf7 100644 --- a/apps/desktop/src/shared/constants.ts +++ b/apps/desktop/src/shared/constants.ts @@ -34,9 +34,6 @@ export const NOTIFICATION_EVENTS = { // Development/testing mock values (used when SKIP_ENV_VALIDATION is set) export const MOCK_ORG_ID = "mock-org-id"; -export const MOCK_USER_ID = "mock-user-id"; -export const MOCK_USER_EMAIL = "dev@localhost"; -export const MOCK_USER_NAME = "Local Dev"; // Default user preference values export const DEFAULT_CONFIRM_ON_QUIT = true; From c77e90d6e88aaed2e986b7eb038d45bee47c9583 Mon Sep 17 00:00:00 2001 From: Lars Kappert <9989650+yigitkonur@users.noreply.github.com> Date: Tue, 17 Feb 2026 19:16:21 -0800 Subject: [PATCH 5/8] fix(desktop): auto-recover terminal reconnect on reopen and harden pbpaste proxy --- .../remote-ssh/clipboard-service.ts | 25 +++++++++--- .../TabsContent/Terminal/Terminal.tsx | 39 +++++++++++-------- .../Terminal/hooks/useTerminalColdRestore.ts | 7 ++++ .../Terminal/hooks/useTerminalLifecycle.ts | 18 +++++++++ 4 files changed, 67 insertions(+), 22 deletions(-) diff --git a/apps/desktop/src/main/lib/workspace-runtime/remote-ssh/clipboard-service.ts b/apps/desktop/src/main/lib/workspace-runtime/remote-ssh/clipboard-service.ts index a4842be9327..14241de7330 100644 --- a/apps/desktop/src/main/lib/workspace-runtime/remote-ssh/clipboard-service.ts +++ b/apps/desktop/src/main/lib/workspace-runtime/remote-ssh/clipboard-service.ts @@ -14,7 +14,7 @@ import type { SFTPService } from "./sftp-service"; const CLIPBOARD_DIR = ".superset/clipboard"; const BIN_DIR = ".superset/bin"; const MAX_CLIPBOARD_FILES = 5; -const PROXY_MARKER = "# superset-clipboard-proxy v5"; +const PROXY_MARKER = "# superset-clipboard-proxy v6"; export class RemoteClipboardService { private connection: SSHConnection; @@ -70,7 +70,7 @@ export class RemoteClipboardService { [ 'export PATH="$HOME/.superset/bin:$PATH"', `if [ -f '${clipboardDir}/latest' ]; then echo latest_exists; else echo latest_missing; fi`, - "if xclip -selection clipboard -t image/png -o >/dev/null 2>&1 || wl-paste --type image/png >/dev/null 2>&1 || pbpaste >/dev/null 2>&1; then echo readImage_ok; else echo readImage_fail; fi", + "if xclip -selection clipboard -t image/png -o >/dev/null 2>&1 || wl-paste --type image/png >/dev/null 2>&1 || SUPERSET_CLIPBOARD_IMAGE=1 pbpaste >/dev/null 2>&1; then echo readImage_ok; else echo readImage_fail; fi", ].join("; "), ); @@ -255,12 +255,27 @@ exit 1 const pbpaste = `#!/bin/bash ${PROXY_MARKER} -# Proxy pbpaste: serve Superset clipboard images for SSH sessions. +# Proxy pbpaste: serve Superset clipboard images for agent image reads only. +# Plain text pbpaste calls must fall through to the real binary. _f="${clipboardDir}/latest" if [[ -f "$_f" ]]; then - cat "$_f" - exit 0 + # Explicit opt-in for image clipboard reads. + if [[ "$SUPERSET_CLIPBOARD_IMAGE" == "1" ]]; then + cat "$_f" + exit 0 + fi + + # Heuristic fallback: allow known agent CLI callers. + # Examples: + # - claude code + # - codex + # - opencode / cursor-agent + _parent_cmd="$(ps -o command= -p "$PPID" 2>/dev/null | tr '[:upper:]' '[:lower:]')" + if [[ "$_parent_cmd" == *"claude"* || "$_parent_cmd" == *"codex"* || "$_parent_cmd" == *"opencode"* || "$_parent_cmd" == *"cursor-agent"* ]]; then + cat "$_f" + exit 0 + fi fi ${findReal("pbpaste")} [[ -n "$_real" ]] && exec "$_real" "$@" diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/Terminal.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/Terminal.tsx index 187d95c5dcc..d5d6d91070f 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/Terminal.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/Terminal.tsx @@ -37,14 +37,6 @@ import { shellEscapePaths } from "./utils"; const stripLeadingEmoji = (text: string) => text.trim().replace(/^[\p{Emoji}\p{Symbol}]\s*/u, ""); -const isTerminalDaemonDisconnect = (message: string) => { - const normalized = message.toLowerCase(); - return ( - normalized.includes("terminal daemon") && - (normalized.includes("lost") || normalized.includes("disconnect")) - ); -}; - export const Terminal = ({ paneId, tabId, workspaceId }: TerminalProps) => { const pane = useTabsStore((s) => s.panes[paneId]); const paneInitialCommands = pane?.initialCommands; @@ -316,6 +308,7 @@ export const Terminal = ({ paneId, tabId, workspaceId }: TerminalProps) => { isStreamReadyRef, didFirstRenderRef, pendingInitialStateRef, + pendingEventsRef, maybeApplyInitialState, flushPendingEvents, resetModes, @@ -360,13 +353,20 @@ export const Terminal = ({ paneId, tabId, workspaceId }: TerminalProps) => { }, [fontSettings]); const terminalBg = terminalTheme?.background ?? getDefaultTerminalBg(); - const autoRetryAttemptsRef = useRef(0); + const [autoRetryAttempts, setAutoRetryAttempts] = useState(0); const autoRetryTimerRef = useRef | null>(null); + const hasCompletedSuccessfulAttachRef = useRef(false); + + useEffect(() => { + if (connectionError) return; + // Once we observe a healthy state, retry policy can be more conservative. + hasCompletedSuccessfulAttachRef.current = true; + }, [connectionError]); useEffect(() => { // Connection recovered: reset retry state. if (!connectionError) { - autoRetryAttemptsRef.current = 0; + setAutoRetryAttempts(0); if (autoRetryTimerRef.current) { clearTimeout(autoRetryTimerRef.current); autoRetryTimerRef.current = null; @@ -374,8 +374,8 @@ export const Terminal = ({ paneId, tabId, workspaceId }: TerminalProps) => { return; } - // Only auto-retry daemon disconnect overlays. - if (!isTerminalDaemonDisconnect(connectionError)) { + const normalized = connectionError.toLowerCase(); + if (normalized.includes("terminal_session_killed")) { return; } @@ -385,16 +385,21 @@ export const Terminal = ({ paneId, tabId, workspaceId }: TerminalProps) => { } // Limit to 3 automatic retries; then keep manual Retry button. - if (autoRetryAttemptsRef.current >= 3) { + if (autoRetryAttempts >= 3) { return; } + // First startup/reopen retry should be fast (equivalent to manual Retry). + const delayMs = + !hasCompletedSuccessfulAttachRef.current && autoRetryAttempts === 0 + ? 250 + : 3000; autoRetryTimerRef.current = setTimeout(() => { autoRetryTimerRef.current = null; - autoRetryAttemptsRef.current += 1; + setAutoRetryAttempts((attempts) => attempts + 1); handleRetryConnection(); - }, 3000); - }, [connectionError, handleRetryConnection]); + }, delayMs); + }, [autoRetryAttempts, connectionError, handleRetryConnection]); useEffect(() => { return () => { @@ -445,7 +450,7 @@ export const Terminal = ({ paneId, tabId, workspaceId }: TerminalProps) => { {exitStatus === "killed" && !connectionError && !isRestoredMode && ( )} - {connectionError && ( + {connectionError && autoRetryAttempts >= 3 && ( )}
diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/hooks/useTerminalColdRestore.ts b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/hooks/useTerminalColdRestore.ts index 6fa0ca50bc6..efb299cd577 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/hooks/useTerminalColdRestore.ts +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/hooks/useTerminalColdRestore.ts @@ -70,6 +70,11 @@ export function useTerminalColdRestore({ }: UseTerminalColdRestoreOptions): UseTerminalColdRestoreReturn { const [isRestoredMode, setIsRestoredMode] = useState(false); const [restoredCwd, setRestoredCwd] = useState(null); + const dropQueuedDisconnectEvents = useCallback(() => { + pendingEventsRef.current = pendingEventsRef.current.filter( + (event) => event.type !== "disconnect", + ); + }, [pendingEventsRef]); // Ref for restoredCwd to use in callbacks const restoredCwdRef = useRef(restoredCwd); @@ -100,6 +105,7 @@ export function useTerminalColdRestore({ if (!currentXterm) return; setConnectionError(null); + dropQueuedDisconnectEvents(); if (result.isColdRestore) { const scrollback = @@ -164,6 +170,7 @@ export function useTerminalColdRestore({ setExitStatus, maybeApplyInitialState, flushPendingEvents, + dropQueuedDisconnectEvents, ]); const handleStartShell = useCallback(() => { diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/hooks/useTerminalLifecycle.ts b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/hooks/useTerminalLifecycle.ts index 1ac1f8ea295..0ae01923401 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/hooks/useTerminalLifecycle.ts +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/hooks/useTerminalLifecycle.ts @@ -26,6 +26,7 @@ import type { TerminalClearScrollbackMutate, TerminalDetachMutate, TerminalResizeMutate, + TerminalStreamEvent, TerminalWriteMutate, } from "../types"; import { scrollToBottom } from "../utils"; @@ -112,6 +113,7 @@ export interface UseTerminalLifecycleOptions { isStreamReadyRef: MutableRefObject; didFirstRenderRef: MutableRefObject; pendingInitialStateRef: MutableRefObject; + pendingEventsRef: MutableRefObject; maybeApplyInitialState: () => void; flushPendingEvents: () => void; resetModes: () => void; @@ -174,6 +176,7 @@ export function useTerminalLifecycle({ isStreamReadyRef, didFirstRenderRef, pendingInitialStateRef, + pendingEventsRef, maybeApplyInitialState, flushPendingEvents, resetModes, @@ -195,6 +198,18 @@ export function useTerminalLifecycle({ const [xtermInstance, setXtermInstance] = useState(null); const restartTerminalRef = useRef<() => void>(() => {}); const restartTerminal = useCallback(() => restartTerminalRef.current(), []); + const dropQueuedDisconnectEvents = useCallback(() => { + const before = pendingEventsRef.current.length; + if (before === 0) return; + pendingEventsRef.current = pendingEventsRef.current.filter( + (event) => event.type !== "disconnect", + ); + if (DEBUG_TERMINAL && pendingEventsRef.current.length !== before) { + console.log( + `[Terminal] Dropped ${before - pendingEventsRef.current.length} stale disconnect event(s): ${paneId}`, + ); + } + }, [paneId, pendingEventsRef]); // biome-ignore lint/correctness/useExhaustiveDependencies: refs used intentionally useEffect(() => { @@ -295,6 +310,7 @@ export function useTerminalLifecycle({ }, { onSuccess: (result) => { + dropQueuedDisconnectEvents(); pendingInitialStateRef.current = result; maybeApplyInitialState(); }, @@ -408,6 +424,7 @@ export function useTerminalLifecycle({ onSuccess: (result) => { if (!isAttachActive()) return; setConnectionError(null); + dropQueuedDisconnectEvents(); if (initialCommands || initialCwd) { clearPaneInitialDataRef.current(paneId); } @@ -632,6 +649,7 @@ export function useTerminalLifecycle({ resetModes, setIsRestoredMode, setRestoredCwd, + dropQueuedDisconnectEvents, ]); return { xtermInstance, restartTerminal }; From e5552ac803ca096e78266f22f8c0e5c06ddd7c43 Mon Sep 17 00:00:00 2001 From: Lars Kappert <9989650+yigitkonur@users.noreply.github.com> Date: Tue, 17 Feb 2026 20:29:01 -0800 Subject: [PATCH 6/8] fix(desktop): preserve SSH terminal sessions across settings navigation Make SSH session detach non-destructive by keeping the SSH channel alive instead of closing it on navigation away. Add session reuse in doCreateOrAttach() so returning from Settings reattaches to the existing live session rather than creating a fresh shell. Clear stale cold-restore state only when a live session is recovered, preventing restored-mode UI from incorrectly reappearing. Also adds all previously untracked remote-ssh module files to version control (were excluded by global lib/ gitignore rule). --- .../main/lib/workspace-runtime/registry.ts | 148 +++++++- .../main/lib/workspace-runtime/remote-ssh.ts | 81 +++++ .../remote-ssh/clipboard-service.ts | 69 +++- .../remote-ssh/connection-pool.ts | 113 ++++++ .../remote-ssh/connection.ts | 293 +++++++++++++++ .../remote-ssh/git-service.ts | 146 ++++++++ .../lib/workspace-runtime/remote-ssh/index.ts | 33 ++ .../remote-ssh/reconnect-strategy.ts | 60 ++++ .../workspace-runtime/remote-ssh/session.ts | 274 ++++++++++++++ .../remote-ssh/sftp-service.ts | 116 ++++++ .../remote-ssh/ssh-key-resolver.ts | 115 ++++++ .../remote-ssh/terminal-runtime.ts | 337 ++++++++++++++++++ .../remote-ssh/tmux-manager.ts | 173 +++++++++ .../lib/workspace-runtime/remote-ssh/types.ts | 30 ++ .../components/SshConnectionsSettings.tsx | 23 +- .../TabsContent/Terminal/helpers.ts | 19 +- .../Terminal/hooks/useClipboardImagePaste.ts | 30 +- .../Terminal/hooks/useTerminalLifecycle.ts | 7 +- packages/local-db/src/schema/schema.ts | 19 +- 19 files changed, 2044 insertions(+), 42 deletions(-) create mode 100644 apps/desktop/src/main/lib/workspace-runtime/remote-ssh.ts create mode 100644 apps/desktop/src/main/lib/workspace-runtime/remote-ssh/connection-pool.ts create mode 100644 apps/desktop/src/main/lib/workspace-runtime/remote-ssh/connection.ts create mode 100644 apps/desktop/src/main/lib/workspace-runtime/remote-ssh/git-service.ts create mode 100644 apps/desktop/src/main/lib/workspace-runtime/remote-ssh/index.ts create mode 100644 apps/desktop/src/main/lib/workspace-runtime/remote-ssh/reconnect-strategy.ts create mode 100644 apps/desktop/src/main/lib/workspace-runtime/remote-ssh/session.ts create mode 100644 apps/desktop/src/main/lib/workspace-runtime/remote-ssh/sftp-service.ts create mode 100644 apps/desktop/src/main/lib/workspace-runtime/remote-ssh/ssh-key-resolver.ts create mode 100644 apps/desktop/src/main/lib/workspace-runtime/remote-ssh/terminal-runtime.ts create mode 100644 apps/desktop/src/main/lib/workspace-runtime/remote-ssh/tmux-manager.ts create mode 100644 apps/desktop/src/main/lib/workspace-runtime/remote-ssh/types.ts diff --git a/apps/desktop/src/main/lib/workspace-runtime/registry.ts b/apps/desktop/src/main/lib/workspace-runtime/registry.ts index ad4759b3411..ca1c0e804db 100644 --- a/apps/desktop/src/main/lib/workspace-runtime/registry.ts +++ b/apps/desktop/src/main/lib/workspace-runtime/registry.ts @@ -18,6 +18,24 @@ import { RemoteSSHWorkspaceRuntime } from "./remote-ssh"; import { getPoolKey } from "./remote-ssh/types"; import type { WorkspaceRuntime, WorkspaceRuntimeRegistry } from "./types"; +const MAX_REMOTE_RUNTIMES = 20; +const REMOTE_RUNTIME_TTL_MS = 30 * 60 * 1000; // 30 minutes +const REMOTE_RUNTIME_CLEANUP_INTERVAL_MS = 5 * 60 * 1000; // 5 minutes + +type RemoteRuntimeEntry = { + runtime: RemoteSSHWorkspaceRuntime; + lastUsedAt: number; +}; + +type RemoteRuntimeConfig = { + id: string; + host: string; + port: number; + username: string; + identityFile?: string; + useAgent?: boolean; +}; + // ============================================================================= // Registry Implementation // ============================================================================= @@ -33,7 +51,15 @@ import type { WorkspaceRuntime, WorkspaceRuntimeRegistry } from "./types"; */ class DefaultWorkspaceRuntimeRegistry implements WorkspaceRuntimeRegistry { private localRuntime: LocalWorkspaceRuntime | null = null; - private remoteRuntimes = new Map(); + private remoteRuntimes = new Map(); + private staleCleanupTimer: ReturnType | null = null; + + constructor() { + this.staleCleanupTimer = setInterval(() => { + this.cleanupStaleRemoteRuntimes(); + }, REMOTE_RUNTIME_CLEANUP_INTERVAL_MS); + this.staleCleanupTimer.unref?.(); + } /** * Get the runtime for a specific workspace. @@ -95,18 +121,125 @@ class DefaultWorkspaceRuntimeRegistry implements WorkspaceRuntimeRegistry { useAgent?: boolean; }): RemoteSSHWorkspaceRuntime { const key = getPoolKey(config); - const existing = this.remoteRuntimes.get(key); - if (existing) return existing; + const existingEntry = this.remoteRuntimes.get(key); + if (existingEntry) { + this.touchRemoteRuntime(key, existingEntry.runtime); + this.ensureRemoteRuntimeHealthy(key, existingEntry.runtime, config); + return existingEntry.runtime; + } const runtime = new RemoteSSHWorkspaceRuntime(config); - this.remoteRuntimes.set(key, runtime); + this.touchRemoteRuntime(key, runtime); + this.enforceRemoteRuntimeCapacity(); // Lazily connect — don't block registry lookup + this.ensureRemoteRuntimeHealthy(key, runtime, config); + + return runtime; + } + + private ensureRemoteRuntimeHealthy( + key: string, + runtime: RemoteSSHWorkspaceRuntime, + config: RemoteRuntimeConfig, + ): void { void runtime.ensureConnected().catch((err) => { - console.error(`[Registry] Failed to connect SSH runtime ${key}:`, err); + const current = this.remoteRuntimes.get(key); + // Runtime was already replaced/evicted; ignore stale failure. + if (!current || current.runtime !== runtime) return; + + console.warn( + `[Registry] SSH runtime unhealthy for ${key}; evicting and retrying with fresh runtime:`, + err, + ); + + this.remoteRuntimes.delete(key); + void runtime.dispose().catch((disposeError) => { + console.warn( + `[Registry] Failed to dispose unhealthy SSH runtime ${key}:`, + disposeError, + ); + }); + + const replacement = new RemoteSSHWorkspaceRuntime(config); + this.touchRemoteRuntime(key, replacement); + this.enforceRemoteRuntimeCapacity(); + + void replacement.ensureConnected().catch((retryErr) => { + const latest = this.remoteRuntimes.get(key); + if (latest?.runtime === replacement) { + this.remoteRuntimes.delete(key); + } + void replacement.dispose().catch((disposeError) => { + console.warn( + `[Registry] Failed to dispose replacement SSH runtime ${key}:`, + disposeError, + ); + }); + console.error( + `[Registry] Failed to reconnect replacement SSH runtime ${key}:`, + retryErr, + ); + }); }); + } - return runtime; + private touchRemoteRuntime( + key: string, + runtime: RemoteSSHWorkspaceRuntime, + ): void { + // Re-insert to update access order (Map preserves insertion order), + // enabling least-recently-used eviction via first key. + this.remoteRuntimes.delete(key); + this.remoteRuntimes.set(key, { + runtime, + lastUsedAt: Date.now(), + }); + } + + private enforceRemoteRuntimeCapacity(): void { + while (this.remoteRuntimes.size > MAX_REMOTE_RUNTIMES) { + const lruKey = this.remoteRuntimes.keys().next().value as + | string + | undefined; + if (!lruKey) return; + this.evictRemoteRuntime(lruKey, "lru"); + } + } + + private cleanupStaleRemoteRuntimes(): void { + const now = Date.now(); + for (const [key, entry] of this.remoteRuntimes.entries()) { + if (now - entry.lastUsedAt > REMOTE_RUNTIME_TTL_MS) { + this.evictRemoteRuntime(key, "ttl"); + } + } + } + + private evictRemoteRuntime(key: string, reason: "lru" | "ttl"): void { + const entry = this.remoteRuntimes.get(key); + if (!entry) return; + this.remoteRuntimes.delete(key); + void entry.runtime.dispose().catch((error) => { + console.warn( + `[Registry] Failed to dispose SSH runtime ${key} during ${reason} eviction:`, + error, + ); + }); + } + + dispose(): void { + if (this.staleCleanupTimer) { + clearInterval(this.staleCleanupTimer); + this.staleCleanupTimer = null; + } + + for (const [key, entry] of this.remoteRuntimes.entries()) { + this.remoteRuntimes.delete(key); + void entry.runtime.dispose().catch((error) => { + console.warn(`[Registry] Failed to dispose SSH runtime ${key}:`, error); + }); + } } } @@ -134,5 +267,8 @@ export function getWorkspaceRuntimeRegistry(): WorkspaceRuntimeRegistry { * This should not be called in production code. */ export function resetWorkspaceRuntimeRegistry(): void { + if (registryInstance instanceof DefaultWorkspaceRuntimeRegistry) { + registryInstance.dispose(); + } registryInstance = null; } diff --git a/apps/desktop/src/main/lib/workspace-runtime/remote-ssh.ts b/apps/desktop/src/main/lib/workspace-runtime/remote-ssh.ts new file mode 100644 index 00000000000..af2882ed021 --- /dev/null +++ b/apps/desktop/src/main/lib/workspace-runtime/remote-ssh.ts @@ -0,0 +1,81 @@ +/** + * Remote SSH Workspace Runtime + * + * Provides WorkspaceRuntime for remote workspaces connected over SSH. + * Uses ssh2 for connections, tmux for session persistence, and SFTP for file access. + */ + +import { + getPoolKey, + RemoteClipboardService, + RemoteGitService, + RemoteSSHTerminalRuntime, + SFTPService, + SSHConnection, + SSHConnectionPool, + type SSHHostConfig, +} from "./remote-ssh/index"; +import type { + TerminalRuntime, + WorkspaceRuntime, + WorkspaceRuntimeId, +} from "./types"; + +/** Shared connection pool across all RemoteSSHWorkspaceRuntime instances */ +const _globalPool = new SSHConnectionPool(); + +export class RemoteSSHWorkspaceRuntime implements WorkspaceRuntime { + readonly id: WorkspaceRuntimeId; + readonly terminal: TerminalRuntime; + readonly capabilities: WorkspaceRuntime["capabilities"]; + readonly git: RemoteGitService; + readonly sftp: SFTPService; + readonly clipboard: RemoteClipboardService; + private connection: SSHConnection | null = null; + + constructor(config: SSHHostConfig) { + this.config = config; + this.id = `remote-ssh:${getPoolKey(config)}`; + + // Create a standalone connection for this runtime + const connection = new SSHConnection(config); + this.connection = connection; + + // Create services + const terminalRuntime = new RemoteSSHTerminalRuntime(connection); + this.terminal = terminalRuntime; + this.git = new RemoteGitService(connection); + this.sftp = new SFTPService(connection); + this.clipboard = new RemoteClipboardService(connection, this.sftp); + + this.capabilities = { + terminal: terminalRuntime.capabilities, + }; + } + + /** + * Lazily initialize the SSH connection. + * Called by the registry before returning the runtime. + */ + async ensureConnected(): Promise { + if (this.connection && !this.connection.isConnected) { + await this.connection.connect(); + } + + // Deploy clipboard proxy scripts after connection is established + await this.clipboard.ensureProxyScripts().catch((err) => { + console.warn( + "[RemoteSSHWorkspaceRuntime] Clipboard proxy deploy failed:", + err, + ); + }); + } + + /** + * Disconnect and clean up. + */ + async dispose(): Promise { + await this.terminal.cleanup(); + this.connection?.disconnect(); + } +} diff --git a/apps/desktop/src/main/lib/workspace-runtime/remote-ssh/clipboard-service.ts b/apps/desktop/src/main/lib/workspace-runtime/remote-ssh/clipboard-service.ts index 14241de7330..8c010a7ff84 100644 --- a/apps/desktop/src/main/lib/workspace-runtime/remote-ssh/clipboard-service.ts +++ b/apps/desktop/src/main/lib/workspace-runtime/remote-ssh/clipboard-service.ts @@ -16,6 +16,29 @@ const BIN_DIR = ".superset/bin"; const MAX_CLIPBOARD_FILES = 5; const PROXY_MARKER = "# superset-clipboard-proxy v6"; +function resolveImageExtension(mimeType: string): string { + const normalized = mimeType.trim().toLowerCase(); + const known: Record = { + "image/jpeg": "jpg", + "image/png": "png", + "image/webp": "webp", + "image/gif": "gif", + "image/svg+xml": "svg", + }; + + const mapped = known[normalized]; + if (mapped) return mapped; + + const [, subtypeRaw = ""] = normalized.split("/", 2); + const subtype = subtypeRaw.split("+")[0] ?? ""; + const sanitized = subtype.replace(/[^a-z0-9]/g, ""); + return sanitized || "bin"; +} + +function shellQuote(value: string): string { + return `'${value.replaceAll("'", "'\"'\"'")}'`; +} + export class RemoteClipboardService { private connection: SSHConnection; private sftp: SFTPService; @@ -30,6 +53,7 @@ export class RemoteClipboardService { setConnection(connection: SSHConnection): void { this.connection = connection; this.proxyDeployed = false; + this._homeDir = null; } /** @@ -39,19 +63,22 @@ export class RemoteClipboardService { async uploadImage(imageBuffer: Buffer, mimeType: string): Promise { const homeDir = await this.getHomeDir(); const clipboardDir = `${homeDir}/${CLIPBOARD_DIR}`; - const ext = mimeType === "image/jpeg" ? "jpg" : "png"; + const ext = resolveImageExtension(mimeType); const filename = `clipboard-${Date.now()}.${ext}`; const remotePath = `${clipboardDir}/${filename}`; + const quotedClipboardDir = shellQuote(clipboardDir); + const quotedRemotePath = shellQuote(remotePath); + const quotedLatestPath = shellQuote(`${clipboardDir}/latest`); // Ensure directory exists - await this.connection.exec(`mkdir -p '${clipboardDir}'`); + await this.connection.exec(`mkdir -p ${quotedClipboardDir}`); // Write the image file await this.sftp.writeFile(remotePath, imageBuffer); // Update the "latest" symlink so proxy scripts always serve the newest image await this.connection.exec( - `ln -sf '${remotePath}' '${clipboardDir}/latest'`, + `ln -sf ${quotedRemotePath} ${quotedLatestPath}`, ); // Cleanup old files in the background @@ -69,7 +96,7 @@ export class RemoteClipboardService { const result = await this.connection.exec( [ 'export PATH="$HOME/.superset/bin:$PATH"', - `if [ -f '${clipboardDir}/latest' ]; then echo latest_exists; else echo latest_missing; fi`, + `if [ -f ${shellQuote(`${clipboardDir}/latest`)} ]; then echo latest_exists; else echo latest_missing; fi`, "if xclip -selection clipboard -t image/png -o >/dev/null 2>&1 || wl-paste --type image/png >/dev/null 2>&1 || SUPERSET_CLIPBOARD_IMAGE=1 pbpaste >/dev/null 2>&1; then echo readImage_ok; else echo readImage_fail; fi", ].join("; "), ); @@ -93,38 +120,60 @@ export class RemoteClipboardService { const homeDir = await this.getHomeDir(); const binDir = `${homeDir}/${BIN_DIR}`; const clipboardDir = `${homeDir}/${CLIPBOARD_DIR}`; + const quotedBinDir = shellQuote(binDir); + const quotedClipboardDir = shellQuote(clipboardDir); - await this.connection.exec(`mkdir -p '${binDir}' '${clipboardDir}'`); + await this.connection.exec( + `mkdir -p ${quotedBinDir} ${quotedClipboardDir}`, + ); const scripts = buildProxyScripts(clipboardDir); for (const [name, content] of Object.entries(scripts)) { const scriptPath = `${binDir}/${name}`; + const quotedScriptPath = shellQuote(scriptPath); // Check if our proxy is already installed const check = await this.connection - .exec(`head -2 '${scriptPath}' 2>/dev/null`) + .exec(`head -2 ${quotedScriptPath} 2>/dev/null`) .catch(() => ({ stdout: "", stderr: "", code: 1 })); if (check.stdout.includes(PROXY_MARKER)) continue; await this.sftp.writeFile(scriptPath, content); - await this.connection.exec(`chmod +x '${scriptPath}'`); + await this.connection.exec(`chmod +x ${quotedScriptPath}`); } this.proxyDeployed = true; } private async cleanupOldFiles(clipboardDir: string): Promise { + const quotedClipboardDir = shellQuote(clipboardDir); await this.connection.exec( - `cd '${clipboardDir}' && ls -t clipboard-* 2>/dev/null | tail -n +${MAX_CLIPBOARD_FILES + 1} | xargs rm -f 2>/dev/null`, + `cd ${quotedClipboardDir} && ls -t clipboard-* 2>/dev/null | tail -n +${MAX_CLIPBOARD_FILES + 1} | xargs rm -f 2>/dev/null`, ); } private async getHomeDir(): Promise { if (this._homeDir) return this._homeDir; - const result = await this.connection.exec("echo $HOME"); - this._homeDir = result.stdout.trim(); + + let result: { stdout: string }; + try { + result = await this.connection.exec("echo $HOME"); + } catch (error) { + throw new Error( + `Failed to resolve remote HOME directory: ${error instanceof Error ? error.message : String(error)}`, + ); + } + + const homeDir = result.stdout.trim(); + if (!homeDir || !homeDir.startsWith("/")) { + throw new Error( + `Invalid remote HOME directory resolved: "${homeDir || ""}"`, + ); + } + + this._homeDir = homeDir; return this._homeDir; } } diff --git a/apps/desktop/src/main/lib/workspace-runtime/remote-ssh/connection-pool.ts b/apps/desktop/src/main/lib/workspace-runtime/remote-ssh/connection-pool.ts new file mode 100644 index 00000000000..6749b0f4ced --- /dev/null +++ b/apps/desktop/src/main/lib/workspace-runtime/remote-ssh/connection-pool.ts @@ -0,0 +1,113 @@ +/** + * SSH Connection Pool + * + * Manages shared SSH connections keyed by "user@host:port". + * Reuses existing connections for the same host to avoid duplicate sessions. + * Disposes idle connections after 60s of inactivity. + */ + +import { SSHConnection } from "./connection"; +import { getPoolKey, type SSHHostConfig } from "./types"; + +const IDLE_TIMEOUT_MS = 60_000; + +interface PoolEntry { + connection: SSHConnection; + refCount: number; + idleTimer: ReturnType | null; +} + +export class SSHConnectionPool { + private pool = new Map(); + + /** + * Get or create a shared connection for the given host config. + * Increments the reference count. + */ + async getOrCreate(config: SSHHostConfig): Promise { + const key = getPoolKey(config); + + const existing = this.pool.get(key); + if (existing) { + existing.refCount++; + if (existing.idleTimer) { + clearTimeout(existing.idleTimer); + existing.idleTimer = null; + } + + // Reconnect if needed + if (!existing.connection.isConnected) { + await existing.connection.connect(); + } + + return existing.connection; + } + + const connection = new SSHConnection(config); + await connection.connect(); + + this.pool.set(key, { + connection, + refCount: 1, + idleTimer: null, + }); + + return connection; + } + + /** + * Release a connection back to the pool. + * Starts the idle timer if no more references. + */ + release(config: SSHHostConfig): void { + const key = getPoolKey(config); + const entry = this.pool.get(key); + if (!entry) return; + + entry.refCount = Math.max(0, entry.refCount - 1); + + if (entry.refCount === 0) { + entry.idleTimer = setTimeout(() => { + this.dispose(key); + }, IDLE_TIMEOUT_MS); + } + } + + /** + * Get a connection if it exists in the pool (without creating). + */ + get(config: SSHHostConfig): SSHConnection | null { + const key = getPoolKey(config); + return this.pool.get(key)?.connection ?? null; + } + + /** + * Dispose a specific connection. + */ + private dispose(key: string): void { + const entry = this.pool.get(key); + if (!entry) return; + + if (entry.idleTimer) { + clearTimeout(entry.idleTimer); + } + entry.connection.disconnect(); + this.pool.delete(key); + } + + /** + * Dispose all connections in the pool. + */ + disposeAll(): void { + for (const [key] of this.pool) { + this.dispose(key); + } + } + + /** + * Get the number of active connections. + */ + get size(): number { + return this.pool.size; + } +} diff --git a/apps/desktop/src/main/lib/workspace-runtime/remote-ssh/connection.ts b/apps/desktop/src/main/lib/workspace-runtime/remote-ssh/connection.ts new file mode 100644 index 00000000000..73db136c101 --- /dev/null +++ b/apps/desktop/src/main/lib/workspace-runtime/remote-ssh/connection.ts @@ -0,0 +1,293 @@ +/** + * SSH Connection + * + * Wraps ssh2.Client with connect/disconnect lifecycle, keepalive, + * auto-reconnect, and convenience methods for shell/exec/sftp. + */ + +import { EventEmitter } from "node:events"; +import type { Client, ClientChannel, ConnectConfig, SFTPWrapper } from "ssh2"; +import { + defaultReconnectStrategy, + type ReconnectStrategy, + waitForReconnect, +} from "./reconnect-strategy"; +import { resolveSSHAuth } from "./ssh-key-resolver"; +import type { SSHConnectionState, SSHHostConfig } from "./types"; + +const KEEPALIVE_INTERVAL = 30_000; +const KEEPALIVE_COUNT_MAX = 3; + +export interface SSHConnectionEvents { + connected: []; + disconnected: [error?: Error]; + reconnecting: [attempt: number]; + error: [error: Error]; + stateChange: [state: SSHConnectionState]; +} + +export class SSHConnection extends EventEmitter { + private client: Client | null = null; + private _state: SSHConnectionState = { status: "disconnected" }; + private reconnectAbort: AbortController | null = null; + private intentionalDisconnect = false; + + readonly config: SSHHostConfig; + private readonly reconnectStrategy: ReconnectStrategy; + + constructor( + config: SSHHostConfig, + reconnectStrategy: ReconnectStrategy = defaultReconnectStrategy, + ) { + super(); + this.config = config; + this.reconnectStrategy = reconnectStrategy; + } + + get state(): SSHConnectionState { + return this._state; + } + + get isConnected(): boolean { + return this._state.status === "connected"; + } + + private setState(state: SSHConnectionState): void { + this._state = state; + this.emit("stateChange", state); + } + + /** + * Establish the SSH connection. + */ + async connect(): Promise { + if (this._state.status === "connected") return; + + this.intentionalDisconnect = false; + this.setState({ status: "connecting" }); + + try { + this.client = await this.createClient(); + this.setState({ status: "connected" }); + this.emit("connected"); + } catch (err) { + const error = err instanceof Error ? err : new Error(String(err)); + this.setState({ + status: "disconnected", + lastError: error.message, + }); + this.emit("error", error); + throw error; + } + } + + /** + * Disconnect and stop any reconnection attempts. + */ + disconnect(): void { + this.intentionalDisconnect = true; + this.reconnectAbort?.abort(); + this.reconnectAbort = null; + + if (this.client) { + this.client.end(); + this.client = null; + } + + this.setState({ status: "disconnected" }); + this.emit("disconnected"); + } + + /** + * Open a PTY shell channel. + */ + async openShell( + cols: number, + rows: number, + env?: Record, + ): Promise { + const client = this.ensureConnected(); + return new Promise((resolve, reject) => { + client.shell( + { + term: "xterm-256color", + cols, + rows, + }, + { env }, + (err, stream) => { + if (err) return reject(err); + resolve(stream); + }, + ); + }); + } + + /** + * Execute a command on the remote host. + */ + async exec( + command: string, + cwd?: string, + ): Promise<{ stdout: string; stderr: string; code: number }> { + const client = this.ensureConnected(); + const fullCommand = cwd + ? `cd ${escapeShellArg(cwd)} && ${command}` + : command; + + return new Promise((resolve, reject) => { + client.exec(fullCommand, (err, stream) => { + if (err) return reject(err); + + let stdout = ""; + let stderr = ""; + + stream.on("data", (data: Buffer) => { + stdout += data.toString(); + }); + stream.stderr.on("data", (data: Buffer) => { + stderr += data.toString(); + }); + stream.on("close", (code: number) => { + resolve({ stdout, stderr, code: code ?? 0 }); + }); + stream.on("error", reject); + }); + }); + } + + /** + * Get an SFTP client. + */ + async getSftp(): Promise { + const client = this.ensureConnected(); + return new Promise((resolve, reject) => { + client.sftp((err, sftp) => { + if (err) return reject(err); + resolve(sftp); + }); + }); + } + + /** + * Get the underlying ssh2.Client (for direct usage when needed). + */ + getClient(): Client { + return this.ensureConnected(); + } + + private ensureConnected(): Client { + if (!this.client || this._state.status !== "connected") { + throw new Error("SSH connection is not established"); + } + return this.client; + } + + private createClient(): Promise { + const { Client: SSH2Client } = require("ssh2") as typeof import("ssh2"); + const client = new SSH2Client(); + + const auth = resolveSSHAuth({ + identityFile: this.config.identityFile, + useAgent: this.config.useAgent, + }); + + const connectConfig: ConnectConfig = { + host: this.config.host, + port: this.config.port, + username: this.config.username, + keepaliveInterval: KEEPALIVE_INTERVAL, + keepaliveCountMax: KEEPALIVE_COUNT_MAX, + readyTimeout: 30_000, + }; + + if (auth.agent) { + connectConfig.agent = auth.agent; + } else if (auth.privateKey) { + connectConfig.privateKey = auth.privateKey; + } + + return new Promise((resolve, reject) => { + const onReady = () => { + cleanup(); + resolve(client); + }; + + const onError = (err: Error) => { + cleanup(); + reject(err); + }; + + const cleanup = () => { + client.removeListener("ready", onReady); + client.removeListener("error", onError); + }; + + client.once("ready", onReady); + client.once("error", onError); + + // Wire up persistent event handlers + client.on("close", () => { + if (this._state.status === "connected" && !this.intentionalDisconnect) { + this.handleUnexpectedDisconnect(); + } + }); + + client.on("error", (err: Error) => { + if (this._state.status === "connected") { + this.emit("error", err); + } + }); + + client.connect(connectConfig); + }); + } + + private handleUnexpectedDisconnect(): void { + this.client = null; + this.emit("disconnected", new Error("Connection lost")); + + // Start reconnection + this.reconnectAbort?.abort(); + this.reconnectAbort = new AbortController(); + void this.reconnectLoop(this.reconnectAbort.signal); + } + + private async reconnectLoop(signal: AbortSignal): Promise { + for ( + let attempt = 0; + attempt < this.reconnectStrategy.maxAttempts; + attempt++ + ) { + if (signal.aborted || this.intentionalDisconnect) return; + + this.setState({ + status: "reconnecting", + reconnectAttempt: attempt + 1, + }); + this.emit("reconnecting", attempt + 1); + + try { + await waitForReconnect(attempt, this.reconnectStrategy, signal); + if (signal.aborted) return; + + this.client = await this.createClient(); + this.setState({ status: "connected" }); + this.emit("connected"); + return; + } catch { + // Continue to next attempt + } + } + + // All attempts exhausted + this.setState({ + status: "disconnected", + lastError: `Failed to reconnect after ${this.reconnectStrategy.maxAttempts} attempts`, + }); + } +} + +function escapeShellArg(arg: string): string { + return `'${arg.replace(/'/g, "'\\''")}'`; +} diff --git a/apps/desktop/src/main/lib/workspace-runtime/remote-ssh/git-service.ts b/apps/desktop/src/main/lib/workspace-runtime/remote-ssh/git-service.ts new file mode 100644 index 00000000000..7e17d8e59e5 --- /dev/null +++ b/apps/desktop/src/main/lib/workspace-runtime/remote-ssh/git-service.ts @@ -0,0 +1,146 @@ +/** + * Remote Git Service + * + * Provides git operations on remote hosts via SSH exec. + */ + +import type { SSHConnection } from "./connection"; + +export interface RemoteWorktreeInfo { + path: string; + branch: string; + head: string; + isBare: boolean; +} + +export class RemoteGitService { + private connection: SSHConnection; + + constructor(connection: SSHConnection) { + this.connection = connection; + } + + setConnection(connection: SSHConnection): void { + this.connection = connection; + } + + /** + * List git worktrees in a remote repository. + */ + async worktreeList(repoPath: string): Promise { + const result = await this.connection.exec( + "git worktree list --porcelain", + repoPath, + ); + + if (result.code !== 0) { + throw new Error(`git worktree list failed: ${result.stderr}`); + } + + const worktrees: RemoteWorktreeInfo[] = []; + let current: Partial = {}; + + for (const line of result.stdout.split("\n")) { + if (line.startsWith("worktree ")) { + if (current.path) worktrees.push(current as RemoteWorktreeInfo); + current = { path: line.slice(9), isBare: false }; + } else if (line.startsWith("HEAD ")) { + current.head = line.slice(5); + } else if (line.startsWith("branch ")) { + current.branch = line.slice(7).replace("refs/heads/", ""); + } else if (line === "bare") { + current.isBare = true; + } + } + + if (current.path) worktrees.push(current as RemoteWorktreeInfo); + return worktrees; + } + + /** + * Add a git worktree on the remote host. + */ + async worktreeAdd( + repoPath: string, + worktreePath: string, + branch: string, + baseBranch?: string, + ): Promise { + const args = baseBranch + ? `git worktree add -b ${branch} ${escapeArg(worktreePath)} ${baseBranch}` + : `git worktree add ${escapeArg(worktreePath)} ${branch}`; + + const result = await this.connection.exec(args, repoPath); + if (result.code !== 0) { + throw new Error(`git worktree add failed: ${result.stderr}`); + } + } + + /** + * Remove a git worktree on the remote host. + */ + async worktreeRemove( + repoPath: string, + worktreePath: string, + force = false, + ): Promise { + const forceFlag = force ? " --force" : ""; + const result = await this.connection.exec( + `git worktree remove${forceFlag} ${escapeArg(worktreePath)}`, + repoPath, + ); + if (result.code !== 0) { + throw new Error(`git worktree remove failed: ${result.stderr}`); + } + } + + /** + * Get the git status of a remote repository. + */ + async status(repoPath: string): Promise { + const result = await this.connection.exec( + "git status --porcelain", + repoPath, + ); + return result.stdout; + } + + /** + * Get the current branch of a remote repository. + */ + async getCurrentBranch(repoPath: string): Promise { + const result = await this.connection.exec( + "git rev-parse --abbrev-ref HEAD", + repoPath, + ); + if (result.code !== 0) { + throw new Error(`Failed to get branch: ${result.stderr}`); + } + return result.stdout.trim(); + } + + /** + * Check if a path exists on the remote host. + */ + async pathExists(remotePath: string): Promise { + const result = await this.connection.exec( + `test -d ${escapeArg(remotePath)} && echo "exists"`, + ); + return result.stdout.trim() === "exists"; + } + + /** + * Check if a path is a git repository. + */ + async isGitRepo(remotePath: string): Promise { + const result = await this.connection.exec( + "git rev-parse --is-inside-work-tree 2>/dev/null", + remotePath, + ); + return result.stdout.trim() === "true"; + } +} + +function escapeArg(arg: string): string { + return `'${arg.replace(/'/g, "'\\''")}'`; +} diff --git a/apps/desktop/src/main/lib/workspace-runtime/remote-ssh/index.ts b/apps/desktop/src/main/lib/workspace-runtime/remote-ssh/index.ts new file mode 100644 index 00000000000..3490433693c --- /dev/null +++ b/apps/desktop/src/main/lib/workspace-runtime/remote-ssh/index.ts @@ -0,0 +1,33 @@ +/** + * Remote SSH Module - Barrel Exports + */ + +export { RemoteClipboardService } from "./clipboard-service"; +export type { SSHConnectionEvents } from "./connection"; +export { SSHConnection } from "./connection"; +export { SSHConnectionPool } from "./connection-pool"; +export type { RemoteWorktreeInfo } from "./git-service"; +export { RemoteGitService } from "./git-service"; +export type { ReconnectStrategy } from "./reconnect-strategy"; +export { + defaultReconnectStrategy, + waitForReconnect, +} from "./reconnect-strategy"; +export type { CreateOrAttachResult, RemoteSessionCallbacks } from "./session"; +export { RemoteSSHSession } from "./session"; +export type { RemoteFileInfo } from "./sftp-service"; +export { SFTPService } from "./sftp-service"; +export type { ResolvedSSHAuth } from "./ssh-key-resolver"; +export { + isSSHAgentAvailable, + listSSHKeys, + resolveSSHAuth, +} from "./ssh-key-resolver"; +export { RemoteSSHTerminalRuntime } from "./terminal-runtime"; +export { TmuxManager } from "./tmux-manager"; +export type { + SSHConnectionPoolKey, + SSHConnectionState, + SSHHostConfig, +} from "./types"; +export { getPoolKey } from "./types"; diff --git a/apps/desktop/src/main/lib/workspace-runtime/remote-ssh/reconnect-strategy.ts b/apps/desktop/src/main/lib/workspace-runtime/remote-ssh/reconnect-strategy.ts new file mode 100644 index 00000000000..1d886a5e406 --- /dev/null +++ b/apps/desktop/src/main/lib/workspace-runtime/remote-ssh/reconnect-strategy.ts @@ -0,0 +1,60 @@ +/** + * Reconnect Strategy + * + * Exponential backoff with jitter for SSH reconnection. + * Delays: 1s, 2s, 4s, 8s, 16s, max 30s + */ + +const BASE_DELAY_MS = 1000; +const MAX_DELAY_MS = 30_000; +const JITTER_FACTOR = 0.3; + +export interface ReconnectStrategy { + /** Get the delay for the next attempt (ms) */ + getDelay(attempt: number): number; + /** Maximum number of reconnect attempts before giving up */ + maxAttempts: number; +} + +function addJitter(delay: number): number { + const jitter = delay * JITTER_FACTOR * (Math.random() * 2 - 1); + return Math.max(0, Math.round(delay + jitter)); +} + +export const defaultReconnectStrategy: ReconnectStrategy = { + maxAttempts: 10, + + getDelay(attempt: number): number { + const exponential = BASE_DELAY_MS * 2 ** attempt; + const capped = Math.min(exponential, MAX_DELAY_MS); + return addJitter(capped); + }, +}; + +/** + * Wait for the reconnect delay, respecting an abort signal. + */ +export function waitForReconnect( + attempt: number, + strategy: ReconnectStrategy = defaultReconnectStrategy, + signal?: AbortSignal, +): Promise { + const delay = strategy.getDelay(attempt); + return new Promise((resolve, reject) => { + if (signal?.aborted) { + reject(new Error("Reconnect aborted")); + return; + } + + const timer = setTimeout(resolve, delay); + + signal?.addEventListener( + "abort", + () => { + clearTimeout(timer); + reject(new Error("Reconnect aborted")); + }, + { once: true }, + ); + }); +} diff --git a/apps/desktop/src/main/lib/workspace-runtime/remote-ssh/session.ts b/apps/desktop/src/main/lib/workspace-runtime/remote-ssh/session.ts new file mode 100644 index 00000000000..d55bdb2f414 --- /dev/null +++ b/apps/desktop/src/main/lib/workspace-runtime/remote-ssh/session.ts @@ -0,0 +1,274 @@ +/** + * Remote SSH Session + * + * Manages a single terminal pane over SSH + tmux. + * Handles creation, attachment, reattachment after reconnection, + * and data flow through callbacks. + */ + +import type { ClientChannel } from "ssh2"; +import type { SSHConnection } from "./connection"; +import { TmuxManager } from "./tmux-manager"; + +export interface RemoteSessionCallbacks { + onData: (data: string) => void; + onExit: (exitCode: number, signal?: number) => void; +} + +export interface CreateOrAttachResult { + isNew: boolean; + wasRecovered: boolean; + initialContent: string; +} + +export class RemoteSSHSession { + readonly paneId: string; + readonly workspaceId: string; + + private connection: SSHConnection; + private tmuxManager: TmuxManager; + private channel: ClientChannel | null = null; + private callbacks: RemoteSessionCallbacks; + private _isAlive = false; + private _isDetached = false; + private _cwd: string; + private _lastActive: number = Date.now(); + private useTmux: boolean; + + constructor(params: { + paneId: string; + workspaceId: string; + connection: SSHConnection; + tmuxManager: TmuxManager; + callbacks: RemoteSessionCallbacks; + useTmux: boolean; + cwd: string; + }) { + this.paneId = params.paneId; + this.workspaceId = params.workspaceId; + this.connection = params.connection; + this.tmuxManager = params.tmuxManager; + this.callbacks = params.callbacks; + this.useTmux = params.useTmux; + this._cwd = params.cwd; + } + + get isAlive(): boolean { + return this._isAlive; + } + + get isDetached(): boolean { + return this._isDetached; + } + + get cwd(): string { + return this._cwd; + } + + get lastActive(): number { + return this._lastActive; + } + + /** + * Create a new session or attach to an existing tmux session. + */ + async createOrAttach( + cols: number, + rows: number, + initialCommands?: string[], + env?: Record, + ): Promise { + const sessionName = TmuxManager.sessionName(this.paneId); + + if (this.useTmux) { + const exists = await this.tmuxManager.hasSession(sessionName); + + if (exists) { + // Recover existing session + const scrollback = + await this.tmuxManager.captureScrollback(sessionName); + await this.tmuxManager.resizeSession(sessionName, cols, rows); + await this.tmuxManager.ensureProxyPath(sessionName); + + this.channel = await this.connection.openShell(cols, rows, env); + this.wireChannel(); + + // Attach to the existing tmux session + this.channel.write(`tmux attach-session -t ${sessionName}\n`); + + this._isAlive = true; + this._lastActive = Date.now(); + + return { + isNew: false, + wasRecovered: true, + initialContent: scrollback, + }; + } + + // Create new tmux session + await this.tmuxManager.createSession(sessionName, cols, rows, this._cwd); + await this.tmuxManager.ensureProxyPath(sessionName); + + this.channel = await this.connection.openShell(cols, rows, env); + this.wireChannel(); + + // Attach and run initial commands + this.channel.write(`tmux attach-session -t ${sessionName}\n`); + + if (initialCommands?.length) { + for (const cmd of initialCommands) { + // Send commands through tmux + await this.tmuxManager.sendKeys(sessionName, cmd).catch(() => { + // Fallback: write directly + this.channel?.write(`${cmd}\n`); + }); + } + } + } else { + // Raw shell mode (no tmux) + this.channel = await this.connection.openShell(cols, rows, env); + this.wireChannel(); + + // Ensure clipboard proxy scripts are on PATH + this.channel.write('export PATH="$HOME/.superset/bin:$PATH"\n'); + + if (this._cwd) { + this.channel.write(`cd ${escapeShellArg(this._cwd)}\n`); + } + + if (initialCommands?.length) { + for (const cmd of initialCommands) { + this.channel.write(`${cmd}\n`); + } + } + } + + this._isAlive = true; + this._lastActive = Date.now(); + + return { + isNew: true, + wasRecovered: false, + initialContent: "", + }; + } + + /** + * Write data to the terminal. + */ + write(data: string): void { + if (!this.channel || !this._isAlive) return; + this._lastActive = Date.now(); + this.channel.write(data); + } + + /** + * Resize the terminal. + */ + resize(cols: number, rows: number): void { + if (!this.channel) return; + this.channel.setWindow(rows, cols, 0, 0); + + if (this.useTmux) { + const sessionName = TmuxManager.sessionName(this.paneId); + this.tmuxManager.resizeSession(sessionName, cols, rows).catch(() => {}); + } + } + + /** + * Send a signal to the terminal process. + */ + signal(sig: string): void { + if (!this.channel) return; + // Send Ctrl+C for SIGINT + if (sig === "SIGINT") { + this.channel.write("\x03"); + } + } + + /** + * Kill the terminal session. + */ + async kill(): Promise { + this._isAlive = false; + this._isDetached = false; + + if (this.useTmux) { + const sessionName = TmuxManager.sessionName(this.paneId); + await this.tmuxManager.killSession(sessionName).catch(() => {}); + } + + if (this.channel) { + this.channel.close(); + this.channel = null; + } + } + + /** + * Detach from the session (keep SSH channel and shell alive). + * Data events continue to fire but are silently discarded since + * no renderer subscription is listening. + */ + detach(): void { + this._isDetached = true; + } + + /** + * Reattach after a local detach (navigation away and back). + * Returns true if the session is still alive and was reattached. + */ + reattachLocal(): boolean { + if (!this._isAlive || !this.channel) return false; + this._isDetached = false; + return true; + } + + /** + * Reattach to the session after SSH reconnection. + */ + async reattach( + connection: SSHConnection, + cols: number, + rows: number, + ): Promise { + this.connection = connection; + + if (!this.useTmux) return false; + + const sessionName = TmuxManager.sessionName(this.paneId); + const exists = await this.tmuxManager.hasSession(sessionName); + if (!exists) return false; + await this.tmuxManager.ensureProxyPath(sessionName); + + this.channel = await connection.openShell(cols, rows); + this.wireChannel(); + this.channel.write(`tmux attach-session -t ${sessionName}\n`); + this._isAlive = true; + + return true; + } + + private wireChannel(): void { + if (!this.channel) return; + + this.channel.on("data", (data: Buffer) => { + this._lastActive = Date.now(); + this.callbacks.onData(data.toString()); + }); + + this.channel.on("close", () => { + this._isAlive = false; + this._isDetached = false; + this.callbacks.onExit(0); + }); + + this.channel.stderr?.on("data", (data: Buffer) => { + this.callbacks.onData(data.toString()); + }); + } +} + +function escapeShellArg(arg: string): string { + return `'${arg.replace(/'/g, "'\\''")}'`; +} diff --git a/apps/desktop/src/main/lib/workspace-runtime/remote-ssh/sftp-service.ts b/apps/desktop/src/main/lib/workspace-runtime/remote-ssh/sftp-service.ts new file mode 100644 index 00000000000..06063496bbf --- /dev/null +++ b/apps/desktop/src/main/lib/workspace-runtime/remote-ssh/sftp-service.ts @@ -0,0 +1,116 @@ +/** + * SFTP Service + * + * Provides remote file access via SFTP subsystem. + */ + +import type { SSHConnection } from "./connection"; + +export interface RemoteFileInfo { + name: string; + path: string; + size: number; + isDirectory: boolean; + modifiedAt: Date; +} + +export class SFTPService { + private connection: SSHConnection; + + constructor(connection: SSHConnection) { + this.connection = connection; + } + + setConnection(connection: SSHConnection): void { + this.connection = connection; + } + + /** + * Read a remote file's contents. + */ + async readFile(remotePath: string): Promise { + const sftp = await this.connection.getSftp(); + return new Promise((resolve, reject) => { + sftp.readFile(remotePath, (err, data) => { + if (err) return reject(err); + resolve(data); + }); + }); + } + + /** + * Read a remote file as a string. + */ + async readFileText( + remotePath: string, + encoding: BufferEncoding = "utf-8", + ): Promise { + const buffer = await this.readFile(remotePath); + return buffer.toString(encoding); + } + + /** + * Write contents to a remote file. + */ + async writeFile(remotePath: string, data: Buffer | string): Promise { + const sftp = await this.connection.getSftp(); + return new Promise((resolve, reject) => { + sftp.writeFile(remotePath, data, (err) => { + if (err) return reject(err); + resolve(); + }); + }); + } + + /** + * List files in a remote directory. + */ + async readdir(remotePath: string): Promise { + const sftp = await this.connection.getSftp(); + return new Promise((resolve, reject) => { + sftp.readdir(remotePath, (err, list) => { + if (err) return reject(err); + resolve( + list.map((entry) => ({ + name: entry.filename, + path: `${remotePath}/${entry.filename}`, + size: entry.attrs.size, + isDirectory: (entry.attrs.mode & 0o40000) !== 0, + modifiedAt: new Date(entry.attrs.mtime * 1000), + })), + ); + }); + }); + } + + /** + * Check if a remote file/directory exists. + */ + async exists(remotePath: string): Promise { + const sftp = await this.connection.getSftp(); + return new Promise((resolve) => { + sftp.stat(remotePath, (err) => { + resolve(!err); + }); + }); + } + + /** + * Get file stats. + */ + async stat( + remotePath: string, + ): Promise<{ size: number; isDirectory: boolean; modifiedAt: Date }> { + const sftp = await this.connection.getSftp(); + return new Promise((resolve, reject) => { + sftp.stat(remotePath, (err, stats) => { + if (err) return reject(err); + resolve({ + size: stats.size, + isDirectory: (stats.mode & 0o40000) !== 0, + modifiedAt: new Date(stats.mtime * 1000), + }); + }); + }); + } +} diff --git a/apps/desktop/src/main/lib/workspace-runtime/remote-ssh/ssh-key-resolver.ts b/apps/desktop/src/main/lib/workspace-runtime/remote-ssh/ssh-key-resolver.ts new file mode 100644 index 00000000000..73ddac3c027 --- /dev/null +++ b/apps/desktop/src/main/lib/workspace-runtime/remote-ssh/ssh-key-resolver.ts @@ -0,0 +1,115 @@ +/** + * SSH Key Resolver + * + * Discovers and loads SSH keys in priority order: + * 1. SSH agent (SSH_AUTH_SOCK) + * 2. Explicit identityFile from config + * 3. ~/.ssh/id_ed25519 + * 4. ~/.ssh/id_rsa + * 5. ~/.ssh/id_ecdsa + */ + +import { existsSync, readFileSync } from "node:fs"; +import { homedir } from "node:os"; +import { join } from "node:path"; + +const DEFAULT_KEY_NAMES = ["id_ed25519", "id_rsa", "id_ecdsa"]; + +export interface ResolvedSSHAuth { + /** Use SSH agent for authentication */ + agent?: string; + /** Private key contents */ + privateKey?: Buffer; + /** Path to the private key file (for diagnostics) */ + keyPath?: string; +} + +/** + * Resolve SSH authentication credentials in priority order. + */ +export function resolveSSHAuth(options?: { + identityFile?: string; + useAgent?: boolean; +}): ResolvedSSHAuth { + const { identityFile, useAgent = true } = options ?? {}; + + // 1. SSH agent + if (useAgent && process.env.SSH_AUTH_SOCK) { + return { agent: process.env.SSH_AUTH_SOCK }; + } + + // 2. Explicit identity file + if (identityFile) { + const resolved = identityFile.startsWith("~") + ? join(homedir(), identityFile.slice(1)) + : identityFile; + if (existsSync(resolved)) { + return { + privateKey: readFileSync(resolved), + keyPath: resolved, + }; + } + } + + // 3. Default key locations + const sshDir = join(homedir(), ".ssh"); + for (const keyName of DEFAULT_KEY_NAMES) { + const keyPath = join(sshDir, keyName); + if (existsSync(keyPath)) { + return { + privateKey: readFileSync(keyPath), + keyPath, + }; + } + } + + return {}; +} + +/** + * List available SSH keys in ~/.ssh (excluding .pub, known_hosts, config, etc.) + */ +export function listSSHKeys(): Array<{ name: string; path: string }> { + const sshDir = join(homedir(), ".ssh"); + const keys: Array<{ name: string; path: string }> = []; + + try { + const { readdirSync } = require("node:fs"); + const entries = readdirSync(sshDir) as string[]; + const exclude = new Set([ + "known_hosts", + "known_hosts.old", + "config", + "authorized_keys", + "environment", + ]); + + for (const entry of entries) { + if (entry.startsWith(".")) continue; + if (entry.endsWith(".pub")) continue; + if (exclude.has(entry)) continue; + + const fullPath = join(sshDir, entry); + try { + const { statSync } = require("node:fs"); + const stat = statSync(fullPath); + if (stat.isFile()) { + keys.push({ name: entry, path: fullPath }); + } + } catch { + // Skip inaccessible files + } + } + } catch { + // ~/.ssh doesn't exist or isn't readable + } + + return keys; +} + +/** + * Check if SSH agent is available. + */ +export function isSSHAgentAvailable(): boolean { + return !!process.env.SSH_AUTH_SOCK; +} diff --git a/apps/desktop/src/main/lib/workspace-runtime/remote-ssh/terminal-runtime.ts b/apps/desktop/src/main/lib/workspace-runtime/remote-ssh/terminal-runtime.ts new file mode 100644 index 00000000000..01d37fe7bc0 --- /dev/null +++ b/apps/desktop/src/main/lib/workspace-runtime/remote-ssh/terminal-runtime.ts @@ -0,0 +1,337 @@ +/** + * Remote SSH Terminal Runtime + * + * Implements TerminalRuntime for remote SSH workspaces using ssh2 + tmux. + * + * Key invariants: + * 1. Stream subscriptions MUST NOT complete on session exit + * 2. Event signatures match exactly: data:${paneId}, exit:${paneId}, etc. + * 3. Sync operations (write, resize, signal, detach) must not block or throw + * 4. Concurrent createOrAttach calls for the same paneId are deduplicated + */ + +import { EventEmitter } from "node:events"; +import type { CreateSessionParams, SessionResult } from "../../terminal/types"; +import type { ListSessionsResponse } from "../../terminal-host/types"; +import type { + TerminalCapabilities, + TerminalManagement, + TerminalRuntime, +} from "../types"; +import type { SSHConnection } from "./connection"; +import { RemoteSSHSession } from "./session"; +import { TmuxManager } from "./tmux-manager"; + +export class RemoteSSHTerminalRuntime + extends EventEmitter + implements TerminalRuntime +{ + readonly capabilities: TerminalCapabilities; + readonly management: TerminalManagement; + + private sessions = new Map(); + private pendingSessions = new Map>(); + private connection: SSHConnection; + private tmuxManager: TmuxManager; + private tmuxAvailable = false; + + constructor(connection: SSHConnection) { + super(); + this.setMaxListeners(100); + + this.connection = connection; + this.tmuxManager = new TmuxManager(connection); + + // Will be updated after tmux availability check + this.capabilities = { + persistent: false, + coldRestore: false, + }; + + this.management = { + listSessions: () => this.listRemoteSessions(), + killAllSessions: () => this.killAllRemoteSessions(), + resetHistoryPersistence: async () => { + /* no-op for SSH */ + }, + }; + + // Wire connection events + this.connection.on("disconnected", () => { + for (const [paneId] of this.sessions) { + this.emit(`disconnect:${paneId}`); + } + }); + + this.connection.on("connected", () => { + // Reattach sessions after reconnection + void this.reattachSessions(); + }); + + // Check tmux availability lazily + void this.initTmuxCapability(); + } + + private async initTmuxCapability(): Promise { + try { + this.tmuxAvailable = await this.tmuxManager.isTmuxAvailable(); + if (this.tmuxAvailable) { + (this.capabilities as { persistent: boolean }).persistent = true; + (this.capabilities as { coldRestore: boolean }).coldRestore = true; + } + } catch { + // tmux not available, use raw shell mode + } + } + + // =========================================================================== + // Session Operations + // =========================================================================== + + async createOrAttach(params: CreateSessionParams): Promise { + const { paneId } = params; + + // Deduplicate concurrent calls for the same paneId + const pending = this.pendingSessions.get(paneId); + if (pending) return pending; + + const promise = this.doCreateOrAttach(params); + this.pendingSessions.set(paneId, promise); + + try { + return await promise; + } finally { + this.pendingSessions.delete(paneId); + } + } + + private async doCreateOrAttach( + params: CreateSessionParams, + ): Promise { + const { + paneId, + workspaceId, + cwd, + cols = 80, + rows = 24, + initialCommands, + } = params; + + // Try to reattach to an existing alive session (e.g. after navigation away/back) + const existing = this.sessions.get(paneId); + if (existing?.isAlive) { + const reattached = existing.reattachLocal(); + if (reattached) { + existing.resize(cols, rows); + + // Capture tmux scrollback if available for buffer restoration + let scrollback = ""; + if (this.tmuxAvailable) { + const sessionName = TmuxManager.sessionName(paneId); + scrollback = await this.tmuxManager + .captureScrollback(sessionName) + .catch(() => ""); + } + + return { + isNew: false, + wasRecovered: true, + scrollback, + }; + } + // Channel died while detached — fall through to create new session + this.sessions.delete(paneId); + } + + const session = new RemoteSSHSession({ + paneId, + workspaceId, + connection: this.connection, + tmuxManager: this.tmuxManager, + useTmux: this.tmuxAvailable, + cwd: cwd || "", + callbacks: { + onData: (data) => { + this.emit(`data:${paneId}`, data); + }, + onExit: (exitCode, signal) => { + // CRITICAL: emit exit event but do NOT complete any subscription + this.emit(`exit:${paneId}`, exitCode, signal); + }, + }, + }); + + this.sessions.set(paneId, session); + + const result = await session.createOrAttach(cols, rows, initialCommands); + + return { + isNew: result.isNew, + scrollback: result.initialContent, + wasRecovered: result.wasRecovered, + }; + } + + write(params: { paneId: string; data: string }): void { + const session = this.sessions.get(params.paneId); + if (!session) return; + session.write(params.data); + } + + resize(params: { paneId: string; cols: number; rows: number }): void { + const session = this.sessions.get(params.paneId); + if (!session) return; + session.resize(params.cols, params.rows); + } + + signal(params: { paneId: string; signal?: string }): void { + const session = this.sessions.get(params.paneId); + if (!session) return; + session.signal(params.signal || "SIGINT"); + } + + async kill(params: { paneId: string }): Promise { + const session = this.sessions.get(params.paneId); + if (!session) return; + await session.kill(); + this.sessions.delete(params.paneId); + } + + detach(params: { paneId: string }): void { + const session = this.sessions.get(params.paneId); + if (!session) return; + session.detach(); + } + + clearScrollback(_params: { paneId: string }): void { + // No-op for SSH sessions + } + + ackColdRestore(_paneId: string): void { + // No-op for SSH sessions + } + + getSession( + paneId: string, + ): { isAlive: boolean; cwd: string; lastActive: number } | null { + const session = this.sessions.get(paneId); + if (!session) return null; + return { + isAlive: session.isAlive, + cwd: session.cwd, + lastActive: session.lastActive, + }; + } + + // =========================================================================== + // Workspace Operations + // =========================================================================== + + async killByWorkspaceId( + workspaceId: string, + ): Promise<{ killed: number; failed: number }> { + let killed = 0; + let failed = 0; + + for (const [paneId, session] of this.sessions) { + if (session.workspaceId === workspaceId) { + try { + await session.kill(); + this.sessions.delete(paneId); + killed++; + } catch { + failed++; + } + } + } + + return { killed, failed }; + } + + async getSessionCountByWorkspaceId(workspaceId: string): Promise { + let count = 0; + for (const session of this.sessions.values()) { + if (session.workspaceId === workspaceId && session.isAlive) { + count++; + } + } + return count; + } + + refreshPromptsForWorkspace(workspaceId: string): void { + for (const session of this.sessions.values()) { + if (session.workspaceId === workspaceId && session.isAlive) { + session.write("\n"); + } + } + } + + // =========================================================================== + // Event Source + // =========================================================================== + + detachAllListeners(): void { + this.removeAllListeners(); + } + + // =========================================================================== + // Management + // =========================================================================== + + private async listRemoteSessions(): Promise { + const sessions: ListSessionsResponse["sessions"] = []; + + for (const [, session] of this.sessions) { + sessions.push({ + sessionId: session.paneId, + workspaceId: session.workspaceId, + paneId: session.paneId, + isAlive: session.isAlive, + attachedClients: session.isAlive ? 1 : 0, + pid: null, + }); + } + + return { sessions }; + } + + private async killAllRemoteSessions(): Promise { + const promises: Promise[] = []; + for (const [paneId, session] of this.sessions) { + promises.push( + session.kill().then(() => { + this.sessions.delete(paneId); + }), + ); + } + await Promise.allSettled(promises); + } + + // =========================================================================== + // Reconnection + // =========================================================================== + + private async reattachSessions(): Promise { + if (!this.tmuxAvailable) return; + + for (const [paneId, session] of this.sessions) { + try { + const reattached = await session.reattach(this.connection, 80, 24); + if (!reattached) { + this.emit(`exit:${paneId}`, 0); + this.sessions.delete(paneId); + } + } catch { + this.emit(`error:${paneId}`, new Error("Failed to reattach session")); + } + } + } + + // =========================================================================== + // Cleanup + // =========================================================================== + + async cleanup(): Promise { + await this.killAllRemoteSessions(); + } +} diff --git a/apps/desktop/src/main/lib/workspace-runtime/remote-ssh/tmux-manager.ts b/apps/desktop/src/main/lib/workspace-runtime/remote-ssh/tmux-manager.ts new file mode 100644 index 00000000000..fd9adda5ad0 --- /dev/null +++ b/apps/desktop/src/main/lib/workspace-runtime/remote-ssh/tmux-manager.ts @@ -0,0 +1,173 @@ +/** + * Tmux Manager + * + * Manages remote tmux sessions for terminal persistence. + * Session naming: superset-{paneId} + * Falls back to raw-shell mode if tmux is not installed. + */ + +import type { SSHConnection } from "./connection"; + +export class TmuxManager { + private connection: SSHConnection; + private _tmuxAvailable: boolean | null = null; + + constructor(connection: SSHConnection) { + this.connection = connection; + } + + /** + * Update the underlying connection (for reconnection scenarios). + */ + setConnection(connection: SSHConnection): void { + this.connection = connection; + } + + /** + * Check if tmux is installed on the remote host. + */ + async isTmuxAvailable(): Promise { + if (this._tmuxAvailable !== null) return this._tmuxAvailable; + + try { + const result = await this.connection.exec("command -v tmux"); + this._tmuxAvailable = result.code === 0; + } catch { + this._tmuxAvailable = false; + } + + return this._tmuxAvailable; + } + + /** + * Check if a tmux session with the given name exists. + */ + async hasSession(name: string): Promise { + try { + const result = await this.connection.exec( + `tmux has-session -t ${escapeSessionName(name)} 2>/dev/null`, + ); + return result.code === 0; + } catch { + return false; + } + } + + /** + * Create a new tmux session. + */ + async createSession( + name: string, + cols: number, + rows: number, + cwd?: string, + ): Promise { + const cdCmd = cwd ? `cd ${escapeShellArg(cwd)} && ` : ""; + const escaped = escapeSessionName(name); + await this.connection.exec( + `${cdCmd}tmux new-session -d -s ${escaped} -x ${cols} -y ${rows}`, + ); + await this.ensureProxyPath(name, { applyToCurrentShell: true }); + } + + /** + * Ensure ~/.superset/bin is available in PATH for this tmux session. + * Safe to call repeatedly (existing sessions, reattach paths, etc.). + */ + async ensureProxyPath( + name: string, + options?: { applyToCurrentShell?: boolean }, + ): Promise { + const escaped = escapeSessionName(name); + await this.connection + .exec( + `tmux set-environment -t ${escaped} PATH "$HOME/.superset/bin:$PATH"`, + ) + .catch(() => {}); + if (options?.applyToCurrentShell) { + await this.connection + .exec( + `tmux send-keys -t ${escaped} 'export PATH="$HOME/.superset/bin:$PATH"' Enter`, + ) + .catch(() => {}); + } + } + + /** + * Capture scrollback from a tmux session. + */ + async captureScrollback(name: string, lines = 1000): Promise { + const result = await this.connection.exec( + `tmux capture-pane -t ${escapeSessionName(name)} -p -S -${lines}`, + ); + return result.stdout; + } + + /** + * Kill a tmux session. + */ + async killSession(name: string): Promise { + await this.connection.exec( + `tmux kill-session -t ${escapeSessionName(name)} 2>/dev/null`, + ); + } + + /** + * List all tmux sessions matching the superset prefix. + */ + async listSessions(): Promise< + Array<{ name: string; created: string; size: string }> + > { + try { + const result = await this.connection.exec( + 'tmux list-sessions -F "#{session_name}|#{session_created}|#{session_width}x#{session_height}" 2>/dev/null', + ); + if (result.code !== 0) return []; + + return result.stdout + .trim() + .split("\n") + .filter((line) => line.startsWith("superset-")) + .map((line) => { + const [name, created, size] = line.split("|"); + return { name, created, size }; + }); + } catch { + return []; + } + } + + /** + * Send keys (a command string) to a tmux session. + */ + async sendKeys(name: string, keys: string): Promise { + await this.connection.exec( + `tmux send-keys -t ${escapeSessionName(name)} ${escapeShellArg(keys)} Enter`, + ); + } + + /** + * Resize a tmux session window. + */ + async resizeSession(name: string, cols: number, rows: number): Promise { + await this.connection.exec( + `tmux resize-window -t ${escapeSessionName(name)} -x ${cols} -y ${rows} 2>/dev/null`, + ); + } + + /** + * Get the tmux session name for a pane ID. + */ + static sessionName(paneId: string): string { + return `superset-${paneId}`; + } +} + +function escapeSessionName(name: string): string { + // Tmux session names can contain alphanumeric, dash, underscore + return name.replace(/[^a-zA-Z0-9_-]/g, "_"); +} + +function escapeShellArg(arg: string): string { + return `'${arg.replace(/'/g, "'\\''")}'`; +} diff --git a/apps/desktop/src/main/lib/workspace-runtime/remote-ssh/types.ts b/apps/desktop/src/main/lib/workspace-runtime/remote-ssh/types.ts new file mode 100644 index 00000000000..d14ed304a0d --- /dev/null +++ b/apps/desktop/src/main/lib/workspace-runtime/remote-ssh/types.ts @@ -0,0 +1,30 @@ +/** + * SSH Connection Types + * + * Configuration and state types for remote SSH workspace connections. + */ + +export interface SSHHostConfig { + /** sshConnections table ID */ + id: string; + host: string; + port: number; + username: string; + /** Explicit key path; if omitted, try agent + defaults */ + identityFile?: string; + /** Use SSH_AUTH_SOCK (default true) */ + useAgent?: boolean; +} + +export interface SSHConnectionState { + status: "disconnected" | "connecting" | "connected" | "reconnecting"; + lastError?: string; + reconnectAttempt?: number; +} + +/** Connection pool key: "user@host:port" */ +export type SSHConnectionPoolKey = string; + +export function getPoolKey(config: SSHHostConfig): SSHConnectionPoolKey { + return `${config.username}@${config.host}:${config.port}`; +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/settings/ssh-connections/components/SshConnectionsSettings.tsx b/apps/desktop/src/renderer/routes/_authenticated/settings/ssh-connections/components/SshConnectionsSettings.tsx index 6fe8f44282f..22d1725299e 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/settings/ssh-connections/components/SshConnectionsSettings.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/settings/ssh-connections/components/SshConnectionsSettings.tsx @@ -202,7 +202,13 @@ export function SshConnectionsSettings({ variant="ghost" size="icon" className="size-7 text-destructive hover:text-destructive" - onClick={() => deleteMutation.mutate({ id: conn.id })} + onClick={() => { + const confirmed = window.confirm( + `Delete SSH connection "${conn.name}"?`, + ); + if (!confirmed) return; + deleteMutation.mutate({ id: conn.id }); + }} > @@ -307,10 +313,12 @@ function SshConnectionDialog({ }; const handleSave = async () => { + if (!isValid) return; + await onSave({ name, host, - port: Number.parseInt(port, 10) || 22, + port: parsedPort, username, authMethod, privateKeyPath: authMethod === "key-file" ? privateKeyPath : null, @@ -322,9 +330,17 @@ function SshConnectionDialog({ if (path) setPrivateKeyPath(path); }; + const parsedPort = Number(port); + const isPortValid = + Number.isInteger(parsedPort) && parsedPort >= 1 && parsedPort <= 65535; + const portError = + port.trim() && !isPortValid + ? "Port must be an integer between 1 and 65535." + : null; const isValid = name.trim() && host.trim() && + isPortValid && username.trim() && (authMethod === "ssh-agent" || privateKeyPath.trim()); @@ -366,6 +382,9 @@ function SshConnectionDialog({ value={port} onChange={(e) => setPort(e.target.value)} /> + {portError && ( +

{portError}

+ )}
diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/helpers.ts b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/helpers.ts index caaf2b274bd..8fa417b12ed 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/helpers.ts +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/helpers.ts @@ -388,7 +388,24 @@ export function setupPasteHandler( if (file) { event.preventDefault(); event.stopImmediatePropagation(); - void options.onImagePaste(file); + options + .onImagePaste(file) + .then((handled) => { + if (!handled) { + console.warn( + "[Terminal] Image paste was not handled by onImagePaste", + ); + } + }) + .catch((error) => { + console.error("[Terminal] Image paste failed:", error); + toast.error("Image paste failed", { + description: + error instanceof Error + ? error.message + : "Unexpected image paste error", + }); + }); return; } } diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/hooks/useClipboardImagePaste.ts b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/hooks/useClipboardImagePaste.ts index a8c5a90130e..912147cc1bc 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/hooks/useClipboardImagePaste.ts +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/hooks/useClipboardImagePaste.ts @@ -140,19 +140,7 @@ export function useClipboardImagePaste({ imageData: base64, mimeType, }); - - const timeoutId = setTimeout(() => { - const pending = pendingPasteRef.current; - if (!pending) return; - if (pending.remotePath !== result.remotePath) return; - pendingPasteRef.current = null; - }, 8000); - - pendingPasteRef.current = { - remotePath: result.remotePath, - mode: "ctrlv", - timeoutId, - }; + pendingPasteRef.current = null; toast.success("Image uploaded to remote clipboard", { id: toastId, @@ -167,9 +155,9 @@ export function useClipboardImagePaste({ }); } - // Send Ctrl+V (\x16) to the terminal so the TUI app triggers - // its own clipboard read — our proxy scripts will serve the image. - onWrite("\x16"); + // Paste uploaded remote image path directly to avoid initial + // clipboard-probe warnings from SSH'd TUI apps. + onWrite(`${quoteForShellInput(result.remotePath)} `); return true; } catch (error) { @@ -190,13 +178,15 @@ export function useClipboardImagePaste({ if (!hasClaudeClipboardMiss(data)) return; + // In path mode we bypass clipboard reads, so ignore clipboard-miss + // output that may be emitted by the app for unrelated reasons. + if (pending.mode === "path") { + return; + } + if (pending.mode === "ctrlv") { pending.mode = "path"; onWrite(`${quoteForShellInput(pending.remotePath)} `); - toast.info("Remote clipboard fallback applied", { - description: "Pasted remote image path after clipboard miss", - duration: 3000, - }); return; } diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/hooks/useTerminalLifecycle.ts b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/hooks/useTerminalLifecycle.ts index 0ae01923401..88cd4be6ef5 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/hooks/useTerminalLifecycle.ts +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/hooks/useTerminalLifecycle.ts @@ -429,6 +429,12 @@ export function useTerminalLifecycle({ clearPaneInitialDataRef.current(paneId); } + // If the session was recovered from a live detached + // channel, stale cold-restore state is no longer relevant. + if (result.wasRecovered) { + coldRestoreState.delete(paneId); + } + const storedColdRestore = coldRestoreState.get(paneId); if (storedColdRestore?.isRestored) { setIsRestoredMode(true); @@ -622,7 +628,6 @@ export function useTerminalLifecycle({ const detachTimeout = setTimeout(() => { detachRef.current({ paneId }); pendingDetaches.delete(paneId); - coldRestoreState.delete(paneId); }, 50); pendingDetaches.set(paneId, detachTimeout); } diff --git a/packages/local-db/src/schema/schema.ts b/packages/local-db/src/schema/schema.ts index f4dbd3968d8..6d6b7bb2579 100644 --- a/packages/local-db/src/schema/schema.ts +++ b/packages/local-db/src/schema/schema.ts @@ -1,4 +1,10 @@ -import { index, integer, sqliteTable, text } from "drizzle-orm/sqlite-core"; +import { + index, + integer, + sqliteTable, + text, + uniqueIndex, +} from "drizzle-orm/sqlite-core"; import { v4 as uuidv4 } from "uuid"; import type { @@ -134,7 +140,12 @@ export const workspaces = sqliteTable( worktreeId: text("worktree_id").references(() => worktrees.id, { onDelete: "cascade", }), // Only set for type="worktree" - sshConnectionId: text("ssh_connection_id"), // Only set for type="remote" + sshConnectionId: text("ssh_connection_id").references( + () => sshConnections.id, + { + onDelete: "set null", + }, + ), // Only set for type="remote" remotePath: text("remote_path"), // Absolute path on remote host type: text("type").notNull().$type(), branch: text("branch").notNull(), // Branch name for both types @@ -289,6 +300,10 @@ export const organizationMembers = sqliteTable( (table) => [ index("organization_members_organization_id_idx").on(table.organization_id), index("organization_members_user_id_idx").on(table.user_id), + uniqueIndex("organization_members_org_user_unique").on( + table.organization_id, + table.user_id, + ), ], ); From 3aa0ee8560af230aa6230cba696a28432981ffdb Mon Sep 17 00:00:00 2001 From: Lars Kappert <9989650+yigitkonur@users.noreply.github.com> Date: Wed, 18 Feb 2026 09:12:19 -0800 Subject: [PATCH 7/8] feat(desktop): wire up changes/files router for SSH workspaces MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Refactor the Changes panel to use a GitRunner abstraction that routes git commands over SSH for remote workspaces. The same parsers work for both local and remote — only the transport changes. Key fix: RemoteGitRunner now awaits ensureConnected() before exec, preventing the race where the Changes tab hit the runtime before the SSH connection was ready. Also add retry + retryDelay on the getBranches query so it recovers instead of getting stuck permanently. Passes workspaceId through the full component tree (ChangesView, CommitInput, FileDiffSection, FileViewerPane, etc.) so every git operation resolves the correct runner. --- apps/desktop/AGENTS.md | 10 + apps/desktop/scripts/fresh-dev.sh | 29 ++ .../src/lib/trpc/routers/changes/branches.ts | 187 ++++++--- .../lib/trpc/routers/changes/file-contents.ts | 369 ++++++++++++------ .../trpc/routers/changes/git-operations.ts | 181 ++++++--- .../routers/changes/security/git-commands.ts | 188 +++++---- .../trpc/routers/changes/security/index.ts | 1 + .../changes/security/path-validation.ts | 36 +- .../src/lib/trpc/routers/changes/staging.ts | 192 +++++++-- .../src/lib/trpc/routers/changes/status.ts | 154 +++++--- .../routers/changes/utils/apply-numstat.ts | 6 +- .../lib/trpc/routers/workspaces/utils/git.ts | 2 +- .../main/lib/workspace-runtime/remote-ssh.ts | 12 +- .../remote-ssh/connection.ts | 75 +++- .../remote-ssh/terminal-runtime.ts | 6 + .../NewWorkspaceModal/NewWorkspaceModal.tsx | 2 + .../workspace/$workspaceId/page.tsx | 5 +- .../ChangesContent/ChangesContent.tsx | 2 + .../FileDiffSection/FileDiffSection.tsx | 4 + .../hooks/useFileDiffEdit/useFileDiffEdit.ts | 6 +- .../InfiniteScrollView/InfiniteScrollView.tsx | 9 +- .../CommitSection/CommitSection.tsx | 4 + .../useFileMutations/useFileMutations.ts | 11 +- .../VirtualizedFileList.tsx | 3 + .../TabView/FileViewerPane/FileViewerPane.tsx | 4 + .../hooks/useFileContent/useFileContent.ts | 9 +- .../hooks/useFileSave/useFileSave.ts | 8 +- .../RightSidebar/ChangesView/ChangesView.tsx | 22 +- .../ChangesHeader/ChangesHeader.tsx | 16 +- .../components/PRButton/PRButton.tsx | 6 +- .../components/CommitInput/CommitInput.tsx | 31 +- .../useGitChangesStatus.ts | 11 +- 32 files changed, 1153 insertions(+), 448 deletions(-) create mode 100755 apps/desktop/scripts/fresh-dev.sh diff --git a/apps/desktop/AGENTS.md b/apps/desktop/AGENTS.md index d3cb1b4453d..e0c2a75cb73 100644 --- a/apps/desktop/AGENTS.md +++ b/apps/desktop/AGENTS.md @@ -1,3 +1,13 @@ +# Post-task verification + +After completing each implementation task, run the fresh-dev script to kill stale processes, clean all caches/build artifacts, and restart the dev server: + +```bash +bash apps/desktop/scripts/fresh-dev.sh +``` + +This ensures changes are tested against a clean state (no stale turbo cache, tsbuildinfo, or dist artifacts). + # Implementation details For Electron interprocess communication, ALWAYS use trpc as defined in `src/lib/trpc` Please use alias as defined in `tsconfig.json` when possible diff --git a/apps/desktop/scripts/fresh-dev.sh b/apps/desktop/scripts/fresh-dev.sh new file mode 100755 index 00000000000..e3d436d1de1 --- /dev/null +++ b/apps/desktop/scripts/fresh-dev.sh @@ -0,0 +1,29 @@ +#!/usr/bin/env bash +set -euo pipefail + +echo "==> Killing Electron / electron-vite / node processes..." +pkill -f "electron-vite" 2>/dev/null || true +pkill -f "electron ." 2>/dev/null || true +# Give processes a moment to die +sleep 0.5 + +REPO_ROOT="$(cd "$(dirname "$0")/../../.." && pwd)" +DESKTOP_DIR="$REPO_ROOT/apps/desktop" + +echo "==> Cleaning turbo cache (repo-wide)..." +rm -rf "$REPO_ROOT/.turbo" + +echo "==> Cleaning desktop build artifacts..." +rm -rf "$DESKTOP_DIR/.turbo" \ + "$DESKTOP_DIR/.cache" \ + "$DESKTOP_DIR/dist" \ + "$DESKTOP_DIR/dist-electron" \ + "$DESKTOP_DIR/node_modules/.dev" + +echo "==> Cleaning tsbuildinfo files..." +find "$REPO_ROOT" -name "*.tsbuildinfo" -type f -delete 2>/dev/null || true +find "$REPO_ROOT" -name "tsbuildinfo.json" -path "*/.cache/*" -type f -delete 2>/dev/null || true + +echo "==> Starting desktop dev server..." +cd "$DESKTOP_DIR" +exec bun dev diff --git a/apps/desktop/src/lib/trpc/routers/changes/branches.ts b/apps/desktop/src/lib/trpc/routers/changes/branches.ts index 792d40f5929..f8fbb937ba9 100644 --- a/apps/desktop/src/lib/trpc/routers/changes/branches.ts +++ b/apps/desktop/src/lib/trpc/routers/changes/branches.ts @@ -1,20 +1,25 @@ -import { worktrees } from "@superset/local-db"; +import { workspaces, worktrees } from "@superset/local-db"; import { eq } from "drizzle-orm"; import { localDb } from "main/lib/local-db"; -import simpleGit from "simple-git"; import { z } from "zod"; import { publicProcedure, router } from "../.."; -import { getCurrentBranch } from "../workspaces/utils/git"; import { - assertRegisteredWorktree, + assertRegisteredWorkspacePath, getRegisteredWorktree, gitSwitchBranch, } from "./security"; +import type { GitRunner } from "./utils/git-runner"; +import { resolveGitTarget } from "./utils/git-runner"; export const createBranchesRouter = () => { return router({ getBranches: publicProcedure - .input(z.object({ worktreePath: z.string() })) + .input( + z.object({ + worktreePath: z.string(), + workspaceId: z.string().optional(), + }), + ) .query( async ({ input, @@ -25,46 +30,66 @@ export const createBranchesRouter = () => { checkedOutBranches: Record; worktreeBaseBranch: string | null; }> => { - assertRegisteredWorktree(input.worktreePath); + assertRegisteredWorkspacePath(input.worktreePath); - const git = simpleGit(input.worktreePath); + try { + const target = resolveGitTarget( + input.worktreePath, + input.workspaceId, + ); + const { runner } = target; - const branchSummary = await git.branch(["-a"]); - const currentBranch = await getCurrentBranch(input.worktreePath); + const currentBranch = await getCurrentBranch(runner); - const gitConfigBase = currentBranch - ? await git - .raw(["config", `branch.${currentBranch}.base`]) - .catch(() => "") - : ""; + const gitConfigBase = currentBranch + ? await runner + .raw(["config", `branch.${currentBranch}.base`]) + .catch(() => "") + : ""; - const localBranches: string[] = []; - const remote: string[] = []; + const branchOutput = await runner.raw(["branch", "-a"]); + const localBranches: string[] = []; + const remote: string[] = []; - for (const name of Object.keys(branchSummary.branches)) { - if (name.startsWith("remotes/origin/")) { - if (name === "remotes/origin/HEAD") continue; - const remoteName = name.replace("remotes/origin/", ""); - remote.push(remoteName); - } else { - localBranches.push(name); + for (const line of branchOutput.split("\n")) { + const name = line.replace(/^\*?\s+/, "").trim(); + if (!name) continue; + if (name.startsWith("remotes/origin/")) { + if (name === "remotes/origin/HEAD") continue; + const remoteName = name.replace("remotes/origin/", ""); + remote.push(remoteName); + } else { + // Skip detached HEAD indicators + if (name.startsWith("(HEAD detached")) continue; + localBranches.push(name); + } } - } - const local = await getLocalBranchesWithDates(git, localBranches); - const defaultBranch = await getDefaultBranch(git, remote); - const checkedOutBranches = await getCheckedOutBranches( - git, - input.worktreePath, - ); - - return { - local, - remote: remote.sort(), - defaultBranch, - checkedOutBranches, - worktreeBaseBranch: gitConfigBase.trim() || null, - }; + const local = await getLocalBranchesWithDates( + runner, + localBranches, + ); + const defaultBranch = await getDefaultBranch(runner, remote); + const checkedOutBranches = await getCheckedOutBranches( + runner, + input.worktreePath, + ); + + return { + local, + remote: remote.sort(), + defaultBranch, + checkedOutBranches, + worktreeBaseBranch: gitConfigBase.trim() || null, + }; + } catch (error) { + console.error( + "[getBranches] Failed for", + input.worktreePath, + error, + ); + throw error; + } }, ), @@ -73,24 +98,44 @@ export const createBranchesRouter = () => { z.object({ worktreePath: z.string(), branch: z.string(), + workspaceId: z.string().optional(), }), ) .mutation(async ({ input }): Promise<{ success: boolean }> => { - const worktree = getRegisteredWorktree(input.worktreePath); - await gitSwitchBranch(input.worktreePath, input.branch); - - const gitStatus = worktree.gitStatus - ? { ...worktree.gitStatus, branch: input.branch } - : null; - - localDb - .update(worktrees) - .set({ - branch: input.branch, - gitStatus, - }) - .where(eq(worktrees.path, input.worktreePath)) - .run(); + const target = resolveGitTarget(input.worktreePath, input.workspaceId); + + await gitSwitchBranch(input.worktreePath, input.branch, target.runner); + + if (target.kind === "remote") { + // Update the workspaces table for remote workspaces + localDb + .update(workspaces) + .set({ + branch: input.branch, + updatedAt: Date.now(), + }) + .where(eq(workspaces.id, target.workspaceId)) + .run(); + } else { + // Update the worktrees table for local workspaces + try { + const worktree = getRegisteredWorktree(input.worktreePath); + const gitStatus = worktree.gitStatus + ? { ...worktree.gitStatus, branch: input.branch } + : null; + + localDb + .update(worktrees) + .set({ + branch: input.branch, + gitStatus, + }) + .where(eq(worktrees.path, input.worktreePath)) + .run(); + } catch { + // Not a worktree entry (e.g. project mainRepoPath) — skip DB update + } + } return { success: true }; }), @@ -100,25 +145,28 @@ export const createBranchesRouter = () => { z.object({ worktreePath: z.string(), baseBranch: z.string().nullable(), + workspaceId: z.string().optional(), }), ) .mutation(async ({ input }): Promise<{ success: boolean }> => { - assertRegisteredWorktree(input.worktreePath); + assertRegisteredWorkspacePath(input.worktreePath); - const git = simpleGit(input.worktreePath); - const currentBranch = await getCurrentBranch(input.worktreePath); + const target = resolveGitTarget(input.worktreePath, input.workspaceId); + const { runner } = target; + + const currentBranch = await getCurrentBranch(runner); if (!currentBranch) { throw new Error("Could not determine current branch"); } if (input.baseBranch) { - await git.raw([ + await runner.raw([ "config", `branch.${currentBranch}.base`, input.baseBranch, ]); } else { - await git + await runner .raw(["config", "--unset", `branch.${currentBranch}.base`]) .catch(() => {}); } @@ -128,12 +176,20 @@ export const createBranchesRouter = () => { }); }; +async function getCurrentBranch(runner: GitRunner): Promise { + try { + return (await runner.raw(["rev-parse", "--abbrev-ref", "HEAD"])).trim(); + } catch { + return null; + } +} + async function getLocalBranchesWithDates( - git: ReturnType, + runner: GitRunner, localBranches: string[], ): Promise> { try { - const branchInfo = await git.raw([ + const branchInfo = await runner.raw([ "for-each-ref", "--sort=-committerdate", "--format=%(refname:short) %(committerdate:unix)", @@ -160,11 +216,14 @@ async function getLocalBranchesWithDates( } async function getDefaultBranch( - git: ReturnType, + runner: GitRunner, remoteBranches: string[], ): Promise { try { - const headRef = await git.raw(["symbolic-ref", "refs/remotes/origin/HEAD"]); + const headRef = await runner.raw([ + "symbolic-ref", + "refs/remotes/origin/HEAD", + ]); const match = headRef.match(/refs\/remotes\/origin\/(.+)/); if (match) { return match[1].trim(); @@ -178,13 +237,13 @@ async function getDefaultBranch( } async function getCheckedOutBranches( - git: ReturnType, + runner: GitRunner, currentWorktreePath: string, ): Promise> { const checkedOutBranches: Record = {}; try { - const worktreeList = await git.raw(["worktree", "list", "--porcelain"]); + const worktreeList = await runner.raw(["worktree", "list", "--porcelain"]); const lines = worktreeList.split("\n"); let currentPath: string | null = null; diff --git a/apps/desktop/src/lib/trpc/routers/changes/file-contents.ts b/apps/desktop/src/lib/trpc/routers/changes/file-contents.ts index ee2183600ca..a85e26e8371 100644 --- a/apps/desktop/src/lib/trpc/routers/changes/file-contents.ts +++ b/apps/desktop/src/lib/trpc/routers/changes/file-contents.ts @@ -1,14 +1,15 @@ import type { FileContents } from "shared/changes-types"; import { detectLanguage } from "shared/detect-language"; import { getImageMimeType } from "shared/file-types"; -import simpleGit from "simple-git"; import { z } from "zod"; import { publicProcedure, router } from "../.."; import { - assertRegisteredWorktree, + assertRegisteredWorkspacePath, PathValidationError, secureFs, } from "./security"; +import type { GitRunner } from "./utils/git-runner"; +import { resolveGitTarget } from "./utils/git-runner"; /** Maximum file size for reading (2 MiB) */ const MAX_FILE_SIZE = 2 * 1024 * 1024; @@ -19,9 +20,6 @@ const MAX_IMAGE_SIZE = 10 * 1024 * 1024; /** Bytes to scan for binary detection */ const BINARY_CHECK_SIZE = 8192; -/** - * Result type for readWorkingFile procedure - */ type ReadWorkingFileResult = | { ok: true; content: string; truncated: boolean; byteLength: number } | { @@ -34,9 +32,6 @@ type ReadWorkingFileResult = | "symlink-escape"; }; -/** - * Result type for readWorkingFileImage procedure - */ type ReadWorkingFileImageResult = | { ok: true; dataUrl: string; byteLength: number } | { @@ -49,9 +44,6 @@ type ReadWorkingFileImageResult = | "symlink-escape"; }; -/** - * Detects if a buffer contains binary content by checking for NUL bytes - */ function isBinaryContent(buffer: Buffer): boolean { const checkLength = Math.min(buffer.length, BINARY_CHECK_SIZE); for (let i = 0; i < checkLength; i++) { @@ -62,6 +54,11 @@ function isBinaryContent(buffer: Buffer): boolean { return false; } +function isBinaryString(content: string): boolean { + const checkLength = Math.min(content.length, BINARY_CHECK_SIZE); + return content.slice(0, checkLength).includes("\0"); +} + export const createFileContentsRouter = () => { return router({ getFileContents: publicProcedure @@ -73,17 +70,19 @@ export const createFileContentsRouter = () => { category: z.enum(["against-base", "committed", "staged", "unstaged"]), commitHash: z.string().optional(), defaultBranch: z.string().optional(), + workspaceId: z.string().optional(), }), ) .query(async ({ input }): Promise => { - assertRegisteredWorktree(input.worktreePath); + assertRegisteredWorkspacePath(input.worktreePath); - const git = simpleGit(input.worktreePath); + const target = resolveGitTarget(input.worktreePath, input.workspaceId); + const { runner } = target; const defaultBranch = input.defaultBranch || "main"; const originalPath = input.oldPath || input.filePath; const { original, modified } = await getFileVersions( - git, + runner, input.worktreePath, input.filePath, originalPath, @@ -105,9 +104,26 @@ export const createFileContentsRouter = () => { worktreePath: z.string(), filePath: z.string(), content: z.string(), + workspaceId: z.string().optional(), }), ) .mutation(async ({ input }): Promise<{ success: boolean }> => { + assertRegisteredWorkspacePath(input.worktreePath); + + const target = resolveGitTarget(input.worktreePath, input.workspaceId); + + if (target.kind === "remote") { + const escapedPath = input.filePath.replace(/'/g, "'\\''"); + // Use heredoc-style write to handle content with special chars + const result = await target.runner.exec( + `cat > '${escapedPath}' << 'SUPERSET_EOF'\n${input.content}\nSUPERSET_EOF`, + ); + if (result.code !== 0) { + throw new Error(result.stderr || "Failed to write file on remote"); + } + return { success: true }; + } + await secureFs.writeFile( input.worktreePath, input.filePath, @@ -116,59 +132,32 @@ export const createFileContentsRouter = () => { return { success: true }; }), - /** - * Read a working tree file safely with size cap and binary detection. - * Used for File Viewer raw/rendered modes. - */ readWorkingFile: publicProcedure .input( z.object({ worktreePath: z.string(), filePath: z.string(), + workspaceId: z.string().optional(), }), ) .query(async ({ input }): Promise => { - try { - const stats = await secureFs.stat(input.worktreePath, input.filePath); - if (stats.size > MAX_FILE_SIZE) { - return { ok: false, reason: "too-large" }; - } + assertRegisteredWorkspacePath(input.worktreePath); - const buffer = await secureFs.readFileBuffer( - input.worktreePath, - input.filePath, - ); + const target = resolveGitTarget(input.worktreePath, input.workspaceId); - if (isBinaryContent(buffer)) { - return { ok: false, reason: "binary" }; - } - - return { - ok: true, - content: buffer.toString("utf-8"), - truncated: false, - byteLength: buffer.length, - }; - } catch (error) { - if (error instanceof PathValidationError) { - if (error.code === "SYMLINK_ESCAPE") { - return { ok: false, reason: "symlink-escape" }; - } - return { ok: false, reason: "outside-worktree" }; - } - return { ok: false, reason: "not-found" }; + if (target.kind === "remote") { + return readWorkingFileRemote(target.runner, input.filePath); } + + return readWorkingFileLocal(input.worktreePath, input.filePath); }), - /** - * Read an image file and return as base64 data URL. - * Used for File Viewer rendered mode for images. - */ readWorkingFileImage: publicProcedure .input( z.object({ worktreePath: z.string(), filePath: z.string(), + workspaceId: z.string().optional(), }), ) .query(async ({ input }): Promise => { @@ -177,38 +166,31 @@ export const createFileContentsRouter = () => { return { ok: false, reason: "not-image" }; } - try { - const stats = await secureFs.stat(input.worktreePath, input.filePath); - if (stats.size > MAX_IMAGE_SIZE) { - return { ok: false, reason: "too-large" }; - } + assertRegisteredWorkspacePath(input.worktreePath); + + const target = resolveGitTarget(input.worktreePath, input.workspaceId); - const buffer = await secureFs.readFileBuffer( - input.worktreePath, + if (target.kind === "remote") { + return readWorkingFileImageRemote( + target.runner, input.filePath, + mimeType, ); - - const base64 = buffer.toString("base64"); - const dataUrl = `data:${mimeType};base64,${base64}`; - - return { - ok: true, - dataUrl, - byteLength: buffer.length, - }; - } catch (error) { - if (error instanceof PathValidationError) { - if (error.code === "SYMLINK_ESCAPE") { - return { ok: false, reason: "symlink-escape" }; - } - return { ok: false, reason: "outside-worktree" }; - } - return { ok: false, reason: "not-found" }; } + + return readWorkingFileImageLocal( + input.worktreePath, + input.filePath, + mimeType, + ); }), }); }; +// ============================================================================= +// File Version Resolution (for diffs) +// ============================================================================= + type DiffCategory = "against-base" | "committed" | "staged" | "unstaged"; interface FileVersions { @@ -217,7 +199,7 @@ interface FileVersions { } async function getFileVersions( - git: ReturnType, + runner: GitRunner, worktreePath: string, filePath: string, originalPath: string, @@ -227,112 +209,271 @@ async function getFileVersions( ): Promise { switch (category) { case "against-base": - return getAgainstBaseVersions(git, filePath, originalPath, defaultBranch); + return getAgainstBaseVersions( + runner, + filePath, + originalPath, + defaultBranch, + ); case "committed": if (!commitHash) { throw new Error("commitHash required for committed category"); } - return getCommittedVersions(git, filePath, originalPath, commitHash); + return getCommittedVersions(runner, filePath, originalPath, commitHash); case "staged": - return getStagedVersions(git, filePath, originalPath); + return getStagedVersions(runner, filePath, originalPath); case "unstaged": - return getUnstagedVersions(git, worktreePath, filePath, originalPath); + return getUnstagedVersions(runner, worktreePath, filePath, originalPath); } } -/** Helper to safely get git show content with size limit and memory protection */ -async function safeGitShow( - git: ReturnType, - spec: string, -): Promise { +async function safeGitShow(runner: GitRunner, spec: string): Promise { try { - // Preflight: check blob size before loading into memory - // This prevents memory spikes from large files in git history - try { - const sizeOutput = await git.raw(["cat-file", "-s", spec]); - const blobSize = Number.parseInt(sizeOutput.trim(), 10); + const sizeResult = await runner.rawSafe(["cat-file", "-s", spec]); + if (sizeResult.code === 0) { + const blobSize = Number.parseInt(sizeResult.stdout.trim(), 10); if (!Number.isNaN(blobSize) && blobSize > MAX_FILE_SIZE) { return `[File content truncated - exceeds ${MAX_FILE_SIZE / 1024 / 1024}MB limit]`; } - } catch { - // cat-file failed (blob doesn't exist) - let git.show handle the error } - const content = await git.show([spec]); - return content; + return await runner.raw(["show", spec]); } catch { return ""; } } async function getAgainstBaseVersions( - git: ReturnType, + runner: GitRunner, filePath: string, originalPath: string, defaultBranch: string, ): Promise { const [original, modified] = await Promise.all([ - safeGitShow(git, `origin/${defaultBranch}:${originalPath}`), - safeGitShow(git, `HEAD:${filePath}`), + safeGitShow(runner, `origin/${defaultBranch}:${originalPath}`), + safeGitShow(runner, `HEAD:${filePath}`), ]); - return { original, modified }; } async function getCommittedVersions( - git: ReturnType, + runner: GitRunner, filePath: string, originalPath: string, commitHash: string, ): Promise { const [original, modified] = await Promise.all([ - safeGitShow(git, `${commitHash}^:${originalPath}`), - safeGitShow(git, `${commitHash}:${filePath}`), + safeGitShow(runner, `${commitHash}^:${originalPath}`), + safeGitShow(runner, `${commitHash}:${filePath}`), ]); - return { original, modified }; } async function getStagedVersions( - git: ReturnType, + runner: GitRunner, filePath: string, originalPath: string, ): Promise { const [original, modified] = await Promise.all([ - safeGitShow(git, `HEAD:${originalPath}`), - safeGitShow(git, `:0:${filePath}`), + safeGitShow(runner, `HEAD:${originalPath}`), + safeGitShow(runner, `:0:${filePath}`), ]); - return { original, modified }; } async function getUnstagedVersions( - git: ReturnType, + runner: GitRunner, worktreePath: string, filePath: string, originalPath: string, ): Promise { // Try staged version first, fall back to HEAD - let original = await safeGitShow(git, `:0:${originalPath}`); + let original = await safeGitShow(runner, `:0:${originalPath}`); if (!original) { - original = await safeGitShow(git, `HEAD:${originalPath}`); + original = await safeGitShow(runner, `HEAD:${originalPath}`); } let modified = ""; + + if (runner.isRemote) { + try { + const escapedPath = filePath.replace(/'/g, "'\\''"); + const statResult = await runner.exec(`stat -c '%s' '${escapedPath}'`); + if (statResult.code === 0) { + const size = Number.parseInt(statResult.stdout.trim(), 10); + if (size > MAX_FILE_SIZE) { + modified = `[File content truncated - exceeds ${MAX_FILE_SIZE / 1024 / 1024}MB limit]`; + return { original, modified }; + } + } + + const catResult = await runner.exec(`cat '${escapedPath}'`); + if (catResult.code === 0) { + modified = catResult.stdout; + } + } catch { + modified = ""; + } + } else { + try { + const stats = await secureFs.stat(worktreePath, filePath); + if (stats.size <= MAX_FILE_SIZE) { + modified = await secureFs.readFile(worktreePath, filePath); + } else { + modified = `[File content truncated - exceeds ${MAX_FILE_SIZE / 1024 / 1024}MB limit]`; + } + } catch { + modified = ""; + } + } + + return { original, modified }; +} + +// ============================================================================= +// Working File Readers +// ============================================================================= + +async function readWorkingFileRemote( + runner: GitRunner, + filePath: string, +): Promise { + try { + const escapedPath = filePath.replace(/'/g, "'\\''"); + + // Check size + const statResult = await runner.exec(`stat -c '%s' '${escapedPath}'`); + if (statResult.code !== 0) { + return { ok: false, reason: "not-found" }; + } + const size = Number.parseInt(statResult.stdout.trim(), 10); + if (size > MAX_FILE_SIZE) { + return { ok: false, reason: "too-large" }; + } + + // Binary check (first 8KB) + const headResult = await runner.exec( + `head -c ${BINARY_CHECK_SIZE} '${escapedPath}'`, + ); + if (headResult.code === 0 && isBinaryString(headResult.stdout)) { + return { ok: false, reason: "binary" }; + } + + // Read full file + const catResult = await runner.exec(`cat '${escapedPath}'`); + if (catResult.code !== 0) { + return { ok: false, reason: "not-found" }; + } + + return { + ok: true, + content: catResult.stdout, + truncated: false, + byteLength: Buffer.byteLength(catResult.stdout, "utf-8"), + }; + } catch { + return { ok: false, reason: "not-found" }; + } +} + +async function readWorkingFileLocal( + worktreePath: string, + filePath: string, +): Promise { try { const stats = await secureFs.stat(worktreePath, filePath); - if (stats.size <= MAX_FILE_SIZE) { - modified = await secureFs.readFile(worktreePath, filePath); - } else { - modified = `[File content truncated - exceeds ${MAX_FILE_SIZE / 1024 / 1024}MB limit]`; + if (stats.size > MAX_FILE_SIZE) { + return { ok: false, reason: "too-large" }; + } + + const buffer = await secureFs.readFileBuffer(worktreePath, filePath); + + if (isBinaryContent(buffer)) { + return { ok: false, reason: "binary" }; + } + + return { + ok: true, + content: buffer.toString("utf-8"), + truncated: false, + byteLength: buffer.length, + }; + } catch (error) { + if (error instanceof PathValidationError) { + if (error.code === "SYMLINK_ESCAPE") { + return { ok: false, reason: "symlink-escape" }; + } + return { ok: false, reason: "outside-worktree" }; } + return { ok: false, reason: "not-found" }; + } +} + +async function readWorkingFileImageRemote( + runner: GitRunner, + filePath: string, + mimeType: string, +): Promise { + try { + const escapedPath = filePath.replace(/'/g, "'\\''"); + + const statResult = await runner.exec(`stat -c '%s' '${escapedPath}'`); + if (statResult.code !== 0) { + return { ok: false, reason: "not-found" }; + } + const size = Number.parseInt(statResult.stdout.trim(), 10); + if (size > MAX_IMAGE_SIZE) { + return { ok: false, reason: "too-large" }; + } + + const result = await runner.exec(`base64 '${escapedPath}'`); + if (result.code !== 0) { + return { ok: false, reason: "not-found" }; + } + + const base64 = result.stdout.replace(/\s/g, ""); + const dataUrl = `data:${mimeType};base64,${base64}`; + + return { + ok: true, + dataUrl, + byteLength: size, + }; } catch { - // File doesn't exist or validation failed - that's ok for diff display - modified = ""; + return { ok: false, reason: "not-found" }; } +} - return { original, modified }; +async function readWorkingFileImageLocal( + worktreePath: string, + filePath: string, + mimeType: string, +): Promise { + try { + const stats = await secureFs.stat(worktreePath, filePath); + if (stats.size > MAX_IMAGE_SIZE) { + return { ok: false, reason: "too-large" }; + } + + const buffer = await secureFs.readFileBuffer(worktreePath, filePath); + const base64 = buffer.toString("base64"); + const dataUrl = `data:${mimeType};base64,${base64}`; + + return { + ok: true, + dataUrl, + byteLength: buffer.length, + }; + } catch (error) { + if (error instanceof PathValidationError) { + if (error.code === "SYMLINK_ESCAPE") { + return { ok: false, reason: "symlink-escape" }; + } + return { ok: false, reason: "outside-worktree" }; + } + return { ok: false, reason: "not-found" }; + } } diff --git a/apps/desktop/src/lib/trpc/routers/changes/git-operations.ts b/apps/desktop/src/lib/trpc/routers/changes/git-operations.ts index 0a9ef07af66..e0994de7c35 100644 --- a/apps/desktop/src/lib/trpc/routers/changes/git-operations.ts +++ b/apps/desktop/src/lib/trpc/routers/changes/git-operations.ts @@ -1,36 +1,37 @@ import { TRPCError } from "@trpc/server"; import { shell } from "electron"; -import simpleGit from "simple-git"; import { z } from "zod"; import { publicProcedure, router } from "../.."; import { execWithShellEnv } from "../workspaces/utils/shell-env"; import { isUpstreamMissingError } from "./git-utils"; -import { assertRegisteredWorktree } from "./security"; +import { assertRegisteredWorkspacePath } from "./security"; +import type { GitRunner } from "./utils/git-runner"; +import { resolveGitTarget } from "./utils/git-runner"; export { isUpstreamMissingError }; -async function hasUpstreamBranch( - git: ReturnType, -): Promise { +async function hasUpstreamBranch(runner: GitRunner): Promise { try { - await git.raw(["rev-parse", "--abbrev-ref", "@{upstream}"]); + await runner.raw(["rev-parse", "--abbrev-ref", "@{upstream}"]); return true; } catch { return false; } } -async function fetchCurrentBranch( - git: ReturnType, -): Promise { - const branch = (await git.revparse(["--abbrev-ref", "HEAD"])).trim(); +async function getCurrentBranch(runner: GitRunner): Promise { + return (await runner.raw(["rev-parse", "--abbrev-ref", "HEAD"])).trim(); +} + +async function fetchCurrentBranchViaRunner(runner: GitRunner): Promise { + const branch = await getCurrentBranch(runner); try { - await git.fetch(["origin", branch]); + await runner.raw(["fetch", "origin", branch]); } catch (error) { const message = error instanceof Error ? error.message : String(error); if (isUpstreamMissingError(message)) { try { - await git.fetch(["origin"]); + await runner.raw(["fetch", "origin"]); } catch (fallbackError) { const fallbackMessage = fallbackError instanceof Error @@ -50,11 +51,11 @@ async function fetchCurrentBranch( } } -async function pushWithSetUpstream({ - git, +async function pushWithSetUpstreamViaRunner({ + runner, branch, }: { - git: ReturnType; + runner: GitRunner; branch: string; }): Promise { const trimmedBranch = branch.trim(); @@ -66,9 +67,8 @@ async function pushWithSetUpstream({ }); } - // Use HEAD refspec to avoid resolving the branch name as a local ref. - // This is more reliable for worktrees where upstream tracking isn't set yet. - await git.push([ + await runner.raw([ + "push", "--set-upstream", "origin", `HEAD:refs/heads/${trimmedBranch}`, @@ -98,15 +98,25 @@ export const createGitOperationsRouter = () => { z.object({ worktreePath: z.string(), message: z.string(), + workspaceId: z.string().optional(), }), ) .mutation( async ({ input }): Promise<{ success: boolean; hash: string }> => { - assertRegisteredWorktree(input.worktreePath); + assertRegisteredWorkspacePath(input.worktreePath); - const git = simpleGit(input.worktreePath); - const result = await git.commit(input.message); - return { success: true, hash: result.commit }; + const { runner } = resolveGitTarget( + input.worktreePath, + input.workspaceId, + ); + + const output = await runner.raw(["commit", "-m", input.message]); + // Extract commit hash from output like "[branch abc1234] message" + const hashMatch = output.match(/\[[\w/.-]+\s+([a-f0-9]+)\]/); + return { + success: true, + hash: hashMatch?.[1] ?? "", + }; }, ), @@ -115,32 +125,37 @@ export const createGitOperationsRouter = () => { z.object({ worktreePath: z.string(), setUpstream: z.boolean().optional(), + workspaceId: z.string().optional(), }), ) .mutation(async ({ input }): Promise<{ success: boolean }> => { - assertRegisteredWorktree(input.worktreePath); + assertRegisteredWorkspacePath(input.worktreePath); + + const { runner } = resolveGitTarget( + input.worktreePath, + input.workspaceId, + ); - const git = simpleGit(input.worktreePath); - const hasUpstream = await hasUpstreamBranch(git); + const upstream = await hasUpstreamBranch(runner); - if (input.setUpstream && !hasUpstream) { - const branch = await git.revparse(["--abbrev-ref", "HEAD"]); - await pushWithSetUpstream({ git, branch }); + if (input.setUpstream && !upstream) { + const branch = await getCurrentBranch(runner); + await pushWithSetUpstreamViaRunner({ runner, branch }); } else { try { - await git.push(); + await runner.raw(["push"]); } catch (error) { const message = error instanceof Error ? error.message : String(error); if (shouldRetryPushWithUpstream(message)) { - const branch = await git.revparse(["--abbrev-ref", "HEAD"]); - await pushWithSetUpstream({ git, branch }); + const branch = await getCurrentBranch(runner); + await pushWithSetUpstreamViaRunner({ runner, branch }); } else { throw error; } } } - await fetchCurrentBranch(git); + await fetchCurrentBranchViaRunner(runner); return { success: true }; }), @@ -148,14 +163,19 @@ export const createGitOperationsRouter = () => { .input( z.object({ worktreePath: z.string(), + workspaceId: z.string().optional(), }), ) .mutation(async ({ input }): Promise<{ success: boolean }> => { - assertRegisteredWorktree(input.worktreePath); + assertRegisteredWorkspacePath(input.worktreePath); + + const { runner } = resolveGitTarget( + input.worktreePath, + input.workspaceId, + ); - const git = simpleGit(input.worktreePath); try { - await git.pull(["--rebase"]); + await runner.raw(["pull", "--rebase"]); } catch (error) { const message = error instanceof Error ? error.message : String(error); @@ -173,36 +193,49 @@ export const createGitOperationsRouter = () => { .input( z.object({ worktreePath: z.string(), + workspaceId: z.string().optional(), }), ) .mutation(async ({ input }): Promise<{ success: boolean }> => { - assertRegisteredWorktree(input.worktreePath); + assertRegisteredWorkspacePath(input.worktreePath); + + const { runner } = resolveGitTarget( + input.worktreePath, + input.workspaceId, + ); - const git = simpleGit(input.worktreePath); try { - await git.pull(["--rebase"]); + await runner.raw(["pull", "--rebase"]); } catch (error) { const message = error instanceof Error ? error.message : String(error); if (isUpstreamMissingError(message)) { - const branch = await git.revparse(["--abbrev-ref", "HEAD"]); - await pushWithSetUpstream({ git, branch }); - await fetchCurrentBranch(git); + const branch = await getCurrentBranch(runner); + await pushWithSetUpstreamViaRunner({ runner, branch }); + await fetchCurrentBranchViaRunner(runner); return { success: true }; } throw error; } - await git.push(); - await fetchCurrentBranch(git); + await runner.raw(["push"]); + await fetchCurrentBranchViaRunner(runner); return { success: true }; }), fetch: publicProcedure - .input(z.object({ worktreePath: z.string() })) + .input( + z.object({ + worktreePath: z.string(), + workspaceId: z.string().optional(), + }), + ) .mutation(async ({ input }): Promise<{ success: boolean }> => { - assertRegisteredWorktree(input.worktreePath); - const git = simpleGit(input.worktreePath); - await fetchCurrentBranch(git); + assertRegisteredWorkspacePath(input.worktreePath); + const { runner } = resolveGitTarget( + input.worktreePath, + input.workspaceId, + ); + await fetchCurrentBranchViaRunner(runner); return { success: true }; }), @@ -210,39 +243,41 @@ export const createGitOperationsRouter = () => { .input( z.object({ worktreePath: z.string(), + workspaceId: z.string().optional(), }), ) .mutation( async ({ input }): Promise<{ success: boolean; url: string }> => { - assertRegisteredWorktree(input.worktreePath); + assertRegisteredWorkspacePath(input.worktreePath); - const git = simpleGit(input.worktreePath); - const branch = (await git.revparse(["--abbrev-ref", "HEAD"])).trim(); - const hasUpstream = await hasUpstreamBranch(git); + const { runner } = resolveGitTarget( + input.worktreePath, + input.workspaceId, + ); - // Ensure branch is pushed first - if (!hasUpstream) { - await pushWithSetUpstream({ git, branch }); + const branch = await getCurrentBranch(runner); + const upstream = await hasUpstreamBranch(runner); + + if (!upstream) { + await pushWithSetUpstreamViaRunner({ runner, branch }); } else { - // Push any unpushed commits try { - await git.push(); + await runner.raw(["push"]); } catch (error) { const message = error instanceof Error ? error.message : String(error); if (shouldRetryPushWithUpstream(message)) { - await pushWithSetUpstream({ git, branch }); + await pushWithSetUpstreamViaRunner({ runner, branch }); } else { throw error; } } } - // Get the remote URL to construct the GitHub compare URL - const remoteUrl = (await git.remote(["get-url", "origin"])) || ""; - const repoMatch = remoteUrl - .trim() - .match(/github\.com[:/](.+?)(?:\.git)?$/); + const remoteUrl = ( + await runner.raw(["remote", "get-url", "origin"]) + ).trim(); + const repoMatch = remoteUrl.match(/github\.com[:/](.+?)(?:\.git)?$/); if (!repoMatch) { throw new Error("Could not determine GitHub repository URL"); @@ -252,7 +287,7 @@ export const createGitOperationsRouter = () => { const url = `https://github.com/${repo}/compare/${branch}?expand=1`; await shell.openExternal(url); - await fetchCurrentBranch(git); + await fetchCurrentBranchViaRunner(runner); return { success: true, url }; }, @@ -263,16 +298,32 @@ export const createGitOperationsRouter = () => { z.object({ worktreePath: z.string(), strategy: z.enum(["merge", "squash", "rebase"]).default("squash"), + workspaceId: z.string().optional(), }), ) .mutation( async ({ input }): Promise<{ success: boolean; mergedAt?: string }> => { - assertRegisteredWorktree(input.worktreePath); + assertRegisteredWorkspacePath(input.worktreePath); + + const target = resolveGitTarget( + input.worktreePath, + input.workspaceId, + ); const args = ["pr", "merge", `--${input.strategy}`]; try { - await execWithShellEnv("gh", args, { cwd: input.worktreePath }); + if (target.kind === "remote") { + // For remote: run gh via SSH exec + const result = await target.runner.exec(`gh ${args.join(" ")}`); + if (result.code !== 0) { + throw new Error(result.stderr || "gh pr merge failed"); + } + } else { + await execWithShellEnv("gh", args, { + cwd: input.worktreePath, + }); + } return { success: true, mergedAt: new Date().toISOString() }; } catch (error) { const message = diff --git a/apps/desktop/src/lib/trpc/routers/changes/security/git-commands.ts b/apps/desktop/src/lib/trpc/routers/changes/security/git-commands.ts index e8b105fb5bf..584c5c6690b 100644 --- a/apps/desktop/src/lib/trpc/routers/changes/security/git-commands.ts +++ b/apps/desktop/src/lib/trpc/routers/changes/security/git-commands.ts @@ -1,30 +1,35 @@ import simpleGit from "simple-git"; import { runWithPostCheckoutHookTolerance } from "../../utils/git-hook-tolerance"; +import type { GitRunner } from "../utils/git-runner"; import { - assertRegisteredWorktree, + assertRegisteredWorkspacePath, assertValidGitPath, } from "./path-validation"; /** * Git command helpers with semantic naming. * - * Design principle: Different functions for different git semantics. - * You can't accidentally use file checkout syntax for branch switching. - * - * Each function: - * 1. Validates worktree is registered - * 2. Validates paths/refs as appropriate - * 3. Uses the correct git command syntax + * Each function accepts an optional GitRunner. If provided, commands run + * through the runner (which may be local or remote). If not provided, + * commands use simpleGit for backwards compatibility with local-only callers. */ async function isCurrentBranch({ worktreePath, expectedBranch, + runner, }: { worktreePath: string; expectedBranch: string; + runner?: GitRunner; }): Promise { try { + if (runner) { + const branch = ( + await runner.raw(["rev-parse", "--abbrev-ref", "HEAD"]) + ).trim(); + return branch === expectedBranch; + } const git = simpleGit(worktreePath); const currentBranch = (await git.revparse(["--abbrev-ref", "HEAD"])).trim(); return currentBranch === expectedBranch; @@ -35,43 +40,51 @@ async function isCurrentBranch({ /** * Switch to a branch. - * - * Uses `git switch` (unambiguous branch operation, git 2.23+). - * Falls back to `git checkout ` for older git versions. - * - * Note: `git checkout -- ` is WRONG - that's file checkout syntax. */ export async function gitSwitchBranch( worktreePath: string, branch: string, + runner?: GitRunner, ): Promise { - assertRegisteredWorktree(worktreePath); + assertRegisteredWorkspacePath(worktreePath); - // Validate: reject anything that looks like a flag if (branch.startsWith("-")) { throw new Error("Invalid branch name: cannot start with -"); } - - // Validate: reject empty branch names if (!branch.trim()) { throw new Error("Invalid branch name: cannot be empty"); } - const git = simpleGit(worktreePath); + if (runner) { + await runWithPostCheckoutHookTolerance({ + context: `Switched branch to "${branch}" in ${worktreePath}`, + run: async () => { + try { + await runner.raw(["switch", branch]); + } catch (switchError) { + const errorMessage = String(switchError); + if (errorMessage.includes("is not a git command")) { + await runner.raw(["checkout", branch]); + } else { + throw switchError; + } + } + }, + didSucceed: async () => + isCurrentBranch({ worktreePath, expectedBranch: branch, runner }), + }); + return; + } + const git = simpleGit(worktreePath); await runWithPostCheckoutHookTolerance({ context: `Switched branch to "${branch}" in ${worktreePath}`, run: async () => { try { - // Prefer `git switch` - unambiguous branch operation (git 2.23+) await git.raw(["switch", branch]); } catch (switchError) { - // Check if it's because `switch` command doesn't exist (old git < 2.23) - // Git outputs: "git: 'switch' is not a git command. See 'git --help'." const errorMessage = String(switchError); if (errorMessage.includes("is not a git command")) { - // Fallback for older git versions - // Note: checkout WITHOUT -- is correct for branches await git.checkout(branch); } else { throw switchError; @@ -85,46 +98,57 @@ export async function gitSwitchBranch( /** * Checkout (restore) a file path, discarding local changes. - * - * Uses `git checkout -- ` - the `--` is REQUIRED here - * to indicate path mode (not branch mode). */ export async function gitCheckoutFile( worktreePath: string, filePath: string, + runner?: GitRunner, ): Promise { - assertRegisteredWorktree(worktreePath); + assertRegisteredWorkspacePath(worktreePath); assertValidGitPath(filePath); + if (runner) { + await runner.raw(["checkout", "--", filePath]); + return; + } + const git = simpleGit(worktreePath); - // `--` is correct here - we want path semantics await git.checkout(["--", filePath]); } /** * Stage a file for commit. - * - * Uses `git add -- ` - the `--` prevents paths starting - * with `-` from being interpreted as flags. */ export async function gitStageFile( worktreePath: string, filePath: string, + runner?: GitRunner, ): Promise { - assertRegisteredWorktree(worktreePath); + assertRegisteredWorkspacePath(worktreePath); assertValidGitPath(filePath); + if (runner) { + await runner.raw(["add", "--", filePath]); + return; + } + const git = simpleGit(worktreePath); await git.add(["--", filePath]); } /** * Stage all changes for commit. - * - * Uses `git add -A` to stage all changes (new, modified, deleted). */ -export async function gitStageAll(worktreePath: string): Promise { - assertRegisteredWorktree(worktreePath); +export async function gitStageAll( + worktreePath: string, + runner?: GitRunner, +): Promise { + assertRegisteredWorkspacePath(worktreePath); + + if (runner) { + await runner.raw(["add", "-A"]); + return; + } const git = simpleGit(worktreePath); await git.add("-A"); @@ -132,29 +156,37 @@ export async function gitStageAll(worktreePath: string): Promise { /** * Unstage a file (remove from staging area). - * - * Uses `git reset HEAD -- ` to unstage without - * discarding changes. */ export async function gitUnstageFile( worktreePath: string, filePath: string, + runner?: GitRunner, ): Promise { - assertRegisteredWorktree(worktreePath); + assertRegisteredWorkspacePath(worktreePath); assertValidGitPath(filePath); + if (runner) { + await runner.raw(["reset", "HEAD", "--", filePath]); + return; + } + const git = simpleGit(worktreePath); await git.reset(["HEAD", "--", filePath]); } /** * Unstage all files. - * - * Uses `git reset HEAD` to unstage all changes without - * discarding them. */ -export async function gitUnstageAll(worktreePath: string): Promise { - assertRegisteredWorktree(worktreePath); +export async function gitUnstageAll( + worktreePath: string, + runner?: GitRunner, +): Promise { + assertRegisteredWorkspacePath(worktreePath); + + if (runner) { + await runner.raw(["reset", "HEAD"]); + return; + } const git = simpleGit(worktreePath); await git.reset(["HEAD"]); @@ -162,14 +194,17 @@ export async function gitUnstageAll(worktreePath: string): Promise { /** * Discard all unstaged changes (modified and deleted files). - * - * Uses `git checkout -- .` to restore all tracked files to HEAD state. - * Does NOT affect untracked files. */ export async function gitDiscardAllUnstaged( worktreePath: string, + runner?: GitRunner, ): Promise { - assertRegisteredWorktree(worktreePath); + assertRegisteredWorkspacePath(worktreePath); + + if (runner) { + await runner.raw(["checkout", "--", "."]); + return; + } const git = simpleGit(worktreePath); await git.checkout(["--", "."]); @@ -177,12 +212,18 @@ export async function gitDiscardAllUnstaged( /** * Discard all staged changes by unstaging then discarding. - * - * Uses `git reset HEAD` followed by `git checkout -- .`. - * Does NOT affect untracked files. */ -export async function gitDiscardAllStaged(worktreePath: string): Promise { - assertRegisteredWorktree(worktreePath); +export async function gitDiscardAllStaged( + worktreePath: string, + runner?: GitRunner, +): Promise { + assertRegisteredWorkspacePath(worktreePath); + + if (runner) { + await runner.raw(["reset", "HEAD"]); + await runner.raw(["checkout", "--", "."]); + return; + } const git = simpleGit(worktreePath); await git.reset(["HEAD"]); @@ -191,11 +232,17 @@ export async function gitDiscardAllStaged(worktreePath: string): Promise { /** * Stash all tracked changes. - * - * Uses `git stash push` to save current work-in-progress. */ -export async function gitStash(worktreePath: string): Promise { - assertRegisteredWorktree(worktreePath); +export async function gitStash( + worktreePath: string, + runner?: GitRunner, +): Promise { + assertRegisteredWorkspacePath(worktreePath); + + if (runner) { + await runner.raw(["stash", "push"]); + return; + } const git = simpleGit(worktreePath); await git.stash(["push"]); @@ -203,13 +250,17 @@ export async function gitStash(worktreePath: string): Promise { /** * Stash all changes including untracked files. - * - * Uses `git stash push --include-untracked`. */ export async function gitStashIncludeUntracked( worktreePath: string, + runner?: GitRunner, ): Promise { - assertRegisteredWorktree(worktreePath); + assertRegisteredWorkspacePath(worktreePath); + + if (runner) { + await runner.raw(["stash", "push", "--include-untracked"]); + return; + } const git = simpleGit(worktreePath); await git.stash(["push", "--include-untracked"]); @@ -217,12 +268,17 @@ export async function gitStashIncludeUntracked( /** * Pop the most recent stash. - * - * Uses `git stash pop` to apply and remove the top stash entry. - * Throws if no stash exists or if there are conflicts. */ -export async function gitStashPop(worktreePath: string): Promise { - assertRegisteredWorktree(worktreePath); +export async function gitStashPop( + worktreePath: string, + runner?: GitRunner, +): Promise { + assertRegisteredWorkspacePath(worktreePath); + + if (runner) { + await runner.raw(["stash", "pop"]); + return; + } const git = simpleGit(worktreePath); await git.stash(["pop"]); diff --git a/apps/desktop/src/lib/trpc/routers/changes/security/index.ts b/apps/desktop/src/lib/trpc/routers/changes/security/index.ts index d147bcc7bcd..7e52a608b9b 100644 --- a/apps/desktop/src/lib/trpc/routers/changes/security/index.ts +++ b/apps/desktop/src/lib/trpc/routers/changes/security/index.ts @@ -23,6 +23,7 @@ export { } from "./git-commands"; export { + assertRegisteredWorkspacePath, assertRegisteredWorktree, assertValidGitPath, getRegisteredWorktree, diff --git a/apps/desktop/src/lib/trpc/routers/changes/security/path-validation.ts b/apps/desktop/src/lib/trpc/routers/changes/security/path-validation.ts index 317994323f3..4b8fd61e292 100644 --- a/apps/desktop/src/lib/trpc/routers/changes/security/path-validation.ts +++ b/apps/desktop/src/lib/trpc/routers/changes/security/path-validation.ts @@ -1,5 +1,5 @@ import { isAbsolute, normalize, resolve, sep } from "node:path"; -import { projects, worktrees } from "@superset/local-db"; +import { projects, workspaces, worktrees } from "@superset/local-db"; import { eq } from "drizzle-orm"; import { localDb } from "main/lib/local-db"; @@ -89,6 +89,40 @@ export function assertRegisteredWorktree(workspacePath: string): void { ); } +/** + * Validates that a workspace path is registered — either as a local + * worktree/project path OR as a remote workspace's remotePath. + * + * This extends assertRegisteredWorktree to support SSH workspaces. + * + * @throws PathValidationError if path is not registered anywhere + */ +export function assertRegisteredWorkspacePath(workspacePath: string): void { + // Try existing local validation first + try { + assertRegisteredWorktree(workspacePath); + return; + } catch { + // Not a local worktree/project — check remote workspaces + } + + // Check workspaces table for remote workspaces + const remoteWorkspace = localDb + .select() + .from(workspaces) + .where(eq(workspaces.remotePath, workspacePath)) + .get(); + + if (remoteWorkspace && remoteWorkspace.type === "remote") { + return; + } + + throw new PathValidationError( + "Workspace path not registered in database", + "UNREGISTERED_WORKTREE", + ); +} + /** * Gets the worktree record if registered. Returns record for updates. * Only works for actual worktrees, not project mainRepoPath. diff --git a/apps/desktop/src/lib/trpc/routers/changes/staging.ts b/apps/desktop/src/lib/trpc/routers/changes/staging.ts index 66c79803261..2b226980109 100644 --- a/apps/desktop/src/lib/trpc/routers/changes/staging.ts +++ b/apps/desktop/src/lib/trpc/routers/changes/staging.ts @@ -1,8 +1,8 @@ -import simpleGit from "simple-git"; import { z } from "zod"; import { publicProcedure, router } from "../.."; +import { parsePortelainStatus } from "../workspaces/utils/git"; import { - assertRegisteredWorktree, + assertRegisteredWorkspacePath, gitCheckoutFile, gitDiscardAllStaged, gitDiscardAllUnstaged, @@ -15,28 +15,53 @@ import { gitUnstageFile, secureFs, } from "./security"; +import type { GitRunner } from "./utils/git-runner"; +import { resolveGitTarget } from "./utils/git-runner"; import { parseGitStatus } from "./utils/parse-status"; -async function getUntrackedFilePaths(worktreePath: string): Promise { - assertRegisteredWorktree(worktreePath); - const git = simpleGit(worktreePath); - const status = await git.status(); - return parseGitStatus(status).untracked.map((f) => f.path); +async function getStatusViaRunner(runner: GitRunner) { + const raw = await runner.raw([ + "--no-optional-locks", + "status", + "--porcelain=v1", + "-b", + "-z", + "-uall", + ]); + return parseGitStatus(parsePortelainStatus(raw)); } -async function getStagedNewFilePaths(worktreePath: string): Promise { - assertRegisteredWorktree(worktreePath); - const git = simpleGit(worktreePath); - const status = await git.status(); - return parseGitStatus(status) - .staged.filter((f) => f.status === "added") - .map((f) => f.path); +async function getUntrackedFilePaths( + worktreePath: string, + runner: GitRunner, +): Promise { + assertRegisteredWorkspacePath(worktreePath); + const parsed = await getStatusViaRunner(runner); + return parsed.untracked.map((f) => f.path); +} + +async function getStagedNewFilePaths( + worktreePath: string, + runner: GitRunner, +): Promise { + assertRegisteredWorkspacePath(worktreePath); + const parsed = await getStatusViaRunner(runner); + return parsed.staged.filter((f) => f.status === "added").map((f) => f.path); } async function deleteFiles( worktreePath: string, filePaths: string[], + runner: GitRunner, ): Promise { + if (runner.isRemote) { + // For remote: use git clean for targeted file deletion + if (filePaths.length > 0) { + await runner.raw(["clean", "-f", "--", ...filePaths]); + } + return; + } + await Promise.all( filePaths.map((filePath) => secureFs.delete(worktreePath, filePath)), ); @@ -49,10 +74,15 @@ export const createStagingRouter = () => { z.object({ worktreePath: z.string(), filePath: z.string(), + workspaceId: z.string().optional(), }), ) .mutation(async ({ input }): Promise<{ success: boolean }> => { - await gitStageFile(input.worktreePath, input.filePath); + const { runner } = resolveGitTarget( + input.worktreePath, + input.workspaceId, + ); + await gitStageFile(input.worktreePath, input.filePath, runner); return { success: true }; }), @@ -61,10 +91,15 @@ export const createStagingRouter = () => { z.object({ worktreePath: z.string(), filePath: z.string(), + workspaceId: z.string().optional(), }), ) .mutation(async ({ input }): Promise<{ success: boolean }> => { - await gitUnstageFile(input.worktreePath, input.filePath); + const { runner } = resolveGitTarget( + input.worktreePath, + input.workspaceId, + ); + await gitUnstageFile(input.worktreePath, input.filePath, runner); return { success: true }; }), @@ -73,24 +108,47 @@ export const createStagingRouter = () => { z.object({ worktreePath: z.string(), filePath: z.string(), + workspaceId: z.string().optional(), }), ) .mutation(async ({ input }): Promise<{ success: boolean }> => { - await gitCheckoutFile(input.worktreePath, input.filePath); + const { runner } = resolveGitTarget( + input.worktreePath, + input.workspaceId, + ); + await gitCheckoutFile(input.worktreePath, input.filePath, runner); return { success: true }; }), stageAll: publicProcedure - .input(z.object({ worktreePath: z.string() })) + .input( + z.object({ + worktreePath: z.string(), + workspaceId: z.string().optional(), + }), + ) .mutation(async ({ input }): Promise<{ success: boolean }> => { - await gitStageAll(input.worktreePath); + const { runner } = resolveGitTarget( + input.worktreePath, + input.workspaceId, + ); + await gitStageAll(input.worktreePath, runner); return { success: true }; }), unstageAll: publicProcedure - .input(z.object({ worktreePath: z.string() })) + .input( + z.object({ + worktreePath: z.string(), + workspaceId: z.string().optional(), + }), + ) .mutation(async ({ input }): Promise<{ success: boolean }> => { - await gitUnstageAll(input.worktreePath); + const { runner } = resolveGitTarget( + input.worktreePath, + input.workspaceId, + ); + await gitUnstageAll(input.worktreePath, runner); return { success: true }; }), @@ -99,51 +157,109 @@ export const createStagingRouter = () => { z.object({ worktreePath: z.string(), filePath: z.string(), + workspaceId: z.string().optional(), }), ) .mutation(async ({ input }): Promise<{ success: boolean }> => { - await secureFs.delete(input.worktreePath, input.filePath); + const { runner } = resolveGitTarget( + input.worktreePath, + input.workspaceId, + ); + if (runner.isRemote) { + await runner.raw(["clean", "-f", "--", input.filePath]); + } else { + await secureFs.delete(input.worktreePath, input.filePath); + } return { success: true }; }), discardAllUnstaged: publicProcedure - .input(z.object({ worktreePath: z.string() })) + .input( + z.object({ + worktreePath: z.string(), + workspaceId: z.string().optional(), + }), + ) .mutation(async ({ input }): Promise<{ success: boolean }> => { - // Must capture untracked files before git checkout removes status info - const untrackedFiles = await getUntrackedFilePaths(input.worktreePath); - await gitDiscardAllUnstaged(input.worktreePath); - await deleteFiles(input.worktreePath, untrackedFiles); + const { runner } = resolveGitTarget( + input.worktreePath, + input.workspaceId, + ); + const untrackedFiles = await getUntrackedFilePaths( + input.worktreePath, + runner, + ); + await gitDiscardAllUnstaged(input.worktreePath, runner); + await deleteFiles(input.worktreePath, untrackedFiles, runner); return { success: true }; }), discardAllStaged: publicProcedure - .input(z.object({ worktreePath: z.string() })) + .input( + z.object({ + worktreePath: z.string(), + workspaceId: z.string().optional(), + }), + ) .mutation(async ({ input }): Promise<{ success: boolean }> => { - // Must capture staged new files before reset makes them untracked - const stagedNewFiles = await getStagedNewFilePaths(input.worktreePath); - await gitDiscardAllStaged(input.worktreePath); - await deleteFiles(input.worktreePath, stagedNewFiles); + const { runner } = resolveGitTarget( + input.worktreePath, + input.workspaceId, + ); + const stagedNewFiles = await getStagedNewFilePaths( + input.worktreePath, + runner, + ); + await gitDiscardAllStaged(input.worktreePath, runner); + await deleteFiles(input.worktreePath, stagedNewFiles, runner); return { success: true }; }), stash: publicProcedure - .input(z.object({ worktreePath: z.string() })) + .input( + z.object({ + worktreePath: z.string(), + workspaceId: z.string().optional(), + }), + ) .mutation(async ({ input }): Promise<{ success: boolean }> => { - await gitStash(input.worktreePath); + const { runner } = resolveGitTarget( + input.worktreePath, + input.workspaceId, + ); + await gitStash(input.worktreePath, runner); return { success: true }; }), stashIncludeUntracked: publicProcedure - .input(z.object({ worktreePath: z.string() })) + .input( + z.object({ + worktreePath: z.string(), + workspaceId: z.string().optional(), + }), + ) .mutation(async ({ input }): Promise<{ success: boolean }> => { - await gitStashIncludeUntracked(input.worktreePath); + const { runner } = resolveGitTarget( + input.worktreePath, + input.workspaceId, + ); + await gitStashIncludeUntracked(input.worktreePath, runner); return { success: true }; }), stashPop: publicProcedure - .input(z.object({ worktreePath: z.string() })) + .input( + z.object({ + worktreePath: z.string(), + workspaceId: z.string().optional(), + }), + ) .mutation(async ({ input }): Promise<{ success: boolean }> => { - await gitStashPop(input.worktreePath); + const { runner } = resolveGitTarget( + input.worktreePath, + input.workspaceId, + ); + await gitStashPop(input.worktreePath, runner); return { success: true }; }), }); diff --git a/apps/desktop/src/lib/trpc/routers/changes/status.ts b/apps/desktop/src/lib/trpc/routers/changes/status.ts index 748201236c1..4962cba897a 100644 --- a/apps/desktop/src/lib/trpc/routers/changes/status.ts +++ b/apps/desktop/src/lib/trpc/routers/changes/status.ts @@ -1,10 +1,11 @@ import type { ChangedFile, GitChangesStatus } from "shared/changes-types"; -import simpleGit from "simple-git"; import { z } from "zod"; import { publicProcedure, router } from "../.."; -import { getStatusNoLock } from "../workspaces/utils/git"; -import { assertRegisteredWorktree, secureFs } from "./security"; +import { parsePortelainStatus } from "../workspaces/utils/git"; +import { assertRegisteredWorkspacePath, secureFs } from "./security"; import { applyNumstatToFiles } from "./utils/apply-numstat"; +import type { GitRunner } from "./utils/git-runner"; +import { resolveGitTarget } from "./utils/git-runner"; import { parseGitLog, parseGitStatus, @@ -18,6 +19,23 @@ const statusCache = new Map< { result: GitChangesStatus; timestamp: number } >(); +/** + * Run `git status --porcelain=v1 -b -z -uall` via the runner and parse + * into the same shape parseGitStatus expects (StatusResult). + */ +async function getStatusViaRunner(runner: GitRunner) { + const raw = await runner.raw([ + "--no-optional-locks", + "status", + "--porcelain=v1", + "-b", + "-z", + "-uall", + ]); + const statusResult = parsePortelainStatus(raw); + return parseGitStatus(statusResult); +} + export const createStatusRouter = () => { return router({ getStatus: publicProcedure @@ -25,52 +43,65 @@ export const createStatusRouter = () => { z.object({ worktreePath: z.string(), defaultBranch: z.string().optional(), + workspaceId: z.string().optional(), }), ) .query(async ({ input }): Promise => { - assertRegisteredWorktree(input.worktreePath); + assertRegisteredWorkspacePath(input.worktreePath); const defaultBranch = input.defaultBranch || "main"; - const cacheKey = `${input.worktreePath}:${defaultBranch}`; + const cacheKey = `${input.workspaceId ?? ""}:${input.worktreePath}:${defaultBranch}`; const cached = statusCache.get(cacheKey); if (cached && Date.now() - cached.timestamp < STATUS_CACHE_TTL_MS) { return cached.result; } - const git = simpleGit(input.worktreePath); - - const status = await getStatusNoLock(input.worktreePath); - const parsed = parseGitStatus(status); - - const [branchComparison, trackingStatus] = await Promise.all([ - getBranchComparison(git, defaultBranch), - getTrackingBranchStatus(git), - applyNumstatToFiles(git, parsed.staged, [ - "diff", - "--cached", - "--numstat", - ]), - applyNumstatToFiles(git, parsed.unstaged, ["diff", "--numstat"]), - applyUntrackedLineCount(input.worktreePath, parsed.untracked), - ]); + try { + const target = resolveGitTarget( + input.worktreePath, + input.workspaceId, + ); + const { runner } = target; + + const parsed = await getStatusViaRunner(runner); + + const [branchComparison, trackingStatus] = await Promise.all([ + getBranchComparison(runner, defaultBranch), + getTrackingBranchStatus(runner), + applyNumstatToFiles(runner, parsed.staged, [ + "diff", + "--cached", + "--numstat", + ]), + applyNumstatToFiles(runner, parsed.unstaged, ["diff", "--numstat"]), + applyUntrackedLineCount( + input.worktreePath, + parsed.untracked, + runner, + ), + ]); + + const result: GitChangesStatus = { + branch: parsed.branch, + defaultBranch, + againstBase: branchComparison.againstBase, + commits: branchComparison.commits, + staged: parsed.staged, + unstaged: parsed.unstaged, + untracked: parsed.untracked, + ahead: branchComparison.ahead, + behind: branchComparison.behind, + pushCount: trackingStatus.pushCount, + pullCount: trackingStatus.pullCount, + hasUpstream: trackingStatus.hasUpstream, + }; - const result: GitChangesStatus = { - branch: parsed.branch, - defaultBranch, - againstBase: branchComparison.againstBase, - commits: branchComparison.commits, - staged: parsed.staged, - unstaged: parsed.unstaged, - untracked: parsed.untracked, - ahead: branchComparison.ahead, - behind: branchComparison.behind, - pushCount: trackingStatus.pushCount, - pullCount: trackingStatus.pullCount, - hasUpstream: trackingStatus.hasUpstream, - }; - - statusCache.set(cacheKey, { result, timestamp: Date.now() }); - return result; + statusCache.set(cacheKey, { result, timestamp: Date.now() }); + return result; + } catch (error) { + console.error("[getStatus] Failed for", input.worktreePath, error); + throw error; + } }), getCommitFiles: publicProcedure @@ -78,14 +109,16 @@ export const createStatusRouter = () => { z.object({ worktreePath: z.string(), commitHash: z.string(), + workspaceId: z.string().optional(), }), ) .query(async ({ input }): Promise => { - assertRegisteredWorktree(input.worktreePath); + assertRegisteredWorkspacePath(input.worktreePath); - const git = simpleGit(input.worktreePath); + const target = resolveGitTarget(input.worktreePath, input.workspaceId); + const { runner } = target; - const nameStatus = await git.raw([ + const nameStatus = await runner.raw([ "diff-tree", "--no-commit-id", "--name-status", @@ -94,7 +127,7 @@ export const createStatusRouter = () => { ]); const files = parseNameStatus(nameStatus); - await applyNumstatToFiles(git, files, [ + await applyNumstatToFiles(runner, files, [ "diff-tree", "--no-commit-id", "--numstat", @@ -115,7 +148,7 @@ interface BranchComparison { } async function getBranchComparison( - git: ReturnType, + runner: GitRunner, defaultBranch: string, ): Promise { let commits: GitChangesStatus["commits"] = []; @@ -124,7 +157,7 @@ async function getBranchComparison( let behind = 0; try { - const tracking = await git.raw([ + const tracking = await runner.raw([ "rev-list", "--left-right", "--count", @@ -134,7 +167,7 @@ async function getBranchComparison( behind = Number.parseInt(behindStr || "0", 10); ahead = Number.parseInt(aheadStr || "0", 10); - const logOutput = await git.raw([ + const logOutput = await runner.raw([ "log", `origin/${defaultBranch}..HEAD`, "--format=%H|%h|%s|%an|%aI", @@ -142,14 +175,14 @@ async function getBranchComparison( commits = parseGitLog(logOutput); if (ahead > 0) { - const nameStatus = await git.raw([ + const nameStatus = await runner.raw([ "diff", "--name-status", `origin/${defaultBranch}...HEAD`, ]); againstBase = parseNameStatus(nameStatus); - await applyNumstatToFiles(git, againstBase, [ + await applyNumstatToFiles(runner, againstBase, [ "diff", "--numstat", `origin/${defaultBranch}...HEAD`, @@ -165,7 +198,28 @@ const MAX_LINE_COUNT_SIZE = 1 * 1024 * 1024; async function applyUntrackedLineCount( worktreePath: string, untracked: ChangedFile[], + runner: GitRunner, ): Promise { + if (runner.isRemote) { + // For remote: batch wc -l for all untracked files + for (const file of untracked) { + try { + const result = await runner.exec( + `wc -l < '${file.path.replace(/'/g, "'\\''")}'`, + ); + if (result.code === 0) { + const lineCount = Number.parseInt(result.stdout.trim(), 10); + if (!Number.isNaN(lineCount)) { + file.additions = lineCount; + file.deletions = 0; + } + } + } catch {} + } + return; + } + + // Local path: use secureFs for (const file of untracked) { try { const stats = await secureFs.stat(worktreePath, file.path); @@ -186,10 +240,10 @@ interface TrackingStatus { } async function getTrackingBranchStatus( - git: ReturnType, + runner: GitRunner, ): Promise { try { - const upstream = await git.raw([ + const upstream = await runner.raw([ "rev-parse", "--abbrev-ref", "@{upstream}", @@ -198,7 +252,7 @@ async function getTrackingBranchStatus( return { pushCount: 0, pullCount: 0, hasUpstream: false }; } - const tracking = await git.raw([ + const tracking = await runner.raw([ "rev-list", "--left-right", "--count", diff --git a/apps/desktop/src/lib/trpc/routers/changes/utils/apply-numstat.ts b/apps/desktop/src/lib/trpc/routers/changes/utils/apply-numstat.ts index 5c6b6c81334..c4f1838975a 100644 --- a/apps/desktop/src/lib/trpc/routers/changes/utils/apply-numstat.ts +++ b/apps/desktop/src/lib/trpc/routers/changes/utils/apply-numstat.ts @@ -1,16 +1,16 @@ import type { ChangedFile } from "shared/changes-types"; -import type { SimpleGit } from "simple-git"; +import type { GitRunner } from "./git-runner"; import { parseDiffNumstat } from "./parse-status"; export async function applyNumstatToFiles( - git: SimpleGit, + runner: GitRunner, files: ChangedFile[], diffArgs: string[], ): Promise { if (files.length === 0) return; try { - const numstat = await git.raw(diffArgs); + const numstat = await runner.raw(diffArgs); const stats = parseDiffNumstat(numstat); for (const file of files) { diff --git a/apps/desktop/src/lib/trpc/routers/workspaces/utils/git.ts b/apps/desktop/src/lib/trpc/routers/workspaces/utils/git.ts index 4266da1e2b2..978a6e6fe17 100644 --- a/apps/desktop/src/lib/trpc/routers/workspaces/utils/git.ts +++ b/apps/desktop/src/lib/trpc/routers/workspaces/utils/git.ts @@ -184,7 +184,7 @@ export async function getStatusNoLock(repoPath: string): Promise { * The -z format uses NUL characters to separate entries, which safely handles * filenames containing spaces, newlines, or other special characters. */ -function parsePortelainStatus(stdout: string): StatusResult { +export function parsePortelainStatus(stdout: string): StatusResult { // Split by NUL character - the -z format separates entries with NUL const entries = stdout.split("\0").filter(Boolean); diff --git a/apps/desktop/src/main/lib/workspace-runtime/remote-ssh.ts b/apps/desktop/src/main/lib/workspace-runtime/remote-ssh.ts index af2882ed021..1f4023bf9c3 100644 --- a/apps/desktop/src/main/lib/workspace-runtime/remote-ssh.ts +++ b/apps/desktop/src/main/lib/workspace-runtime/remote-ssh.ts @@ -34,7 +34,6 @@ export class RemoteSSHWorkspaceRuntime implements WorkspaceRuntime { private connection: SSHConnection | null = null; constructor(config: SSHHostConfig) { - this.config = config; this.id = `remote-ssh:${getPoolKey(config)}`; // Create a standalone connection for this runtime @@ -71,6 +70,17 @@ export class RemoteSSHWorkspaceRuntime implements WorkspaceRuntime { }); } + /** + * Get the underlying SSH connection for direct exec access. + * Used by GitRunner for remote git commands. + */ + getConnection(): SSHConnection { + if (!this.connection) { + throw new Error("SSH connection is not initialized"); + } + return this.connection; + } + /** * Disconnect and clean up. */ diff --git a/apps/desktop/src/main/lib/workspace-runtime/remote-ssh/connection.ts b/apps/desktop/src/main/lib/workspace-runtime/remote-ssh/connection.ts index 73db136c101..33e94472ba5 100644 --- a/apps/desktop/src/main/lib/workspace-runtime/remote-ssh/connection.ts +++ b/apps/desktop/src/main/lib/workspace-runtime/remote-ssh/connection.ts @@ -29,6 +29,7 @@ export interface SSHConnectionEvents { export class SSHConnection extends EventEmitter { private client: Client | null = null; private _state: SSHConnectionState = { status: "disconnected" }; + private connectInFlight: Promise | null = null; private reconnectAbort: AbortController | null = null; private intentionalDisconnect = false; @@ -62,23 +63,39 @@ export class SSHConnection extends EventEmitter { */ async connect(): Promise { if (this._state.status === "connected") return; + if (this.connectInFlight) return this.connectInFlight; + + // If a background reconnect loop is already active, wait for its outcome + // instead of starting a second parallel connection attempt. + if (this._state.status === "reconnecting") { + await this.waitForReconnectResult(); + return; + } this.intentionalDisconnect = false; this.setState({ status: "connecting" }); - try { - this.client = await this.createClient(); - this.setState({ status: "connected" }); - this.emit("connected"); - } catch (err) { - const error = err instanceof Error ? err : new Error(String(err)); - this.setState({ - status: "disconnected", - lastError: error.message, - }); - this.emit("error", error); - throw error; - } + const attempt = (async () => { + try { + this.client = await this.createClient(); + this.setState({ status: "connected" }); + this.emit("connected"); + } catch (err) { + const error = err instanceof Error ? err : new Error(String(err)); + this.setState({ + status: "disconnected", + lastError: error.message, + }); + this.emit("error", error); + throw error; + } + })(); + + this.connectInFlight = attempt.finally(() => { + this.connectInFlight = null; + }); + + await this.connectInFlight; } /** @@ -253,6 +270,38 @@ export class SSHConnection extends EventEmitter { void this.reconnectLoop(this.reconnectAbort.signal); } + private waitForReconnectResult(timeoutMs = 35_000): Promise { + if (this._state.status === "connected") return Promise.resolve(); + + return new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + cleanup(); + reject(new Error("Timed out waiting for SSH reconnection")); + }, timeoutMs); + + const onConnected = () => { + cleanup(); + resolve(); + }; + + const onStateChange = (state: SSHConnectionState) => { + if (state.status === "disconnected") { + cleanup(); + reject(new Error(state.lastError || "SSH reconnection failed")); + } + }; + + const cleanup = () => { + clearTimeout(timeout); + this.removeListener("connected", onConnected); + this.removeListener("stateChange", onStateChange); + }; + + this.on("connected", onConnected); + this.on("stateChange", onStateChange); + }); + } + private async reconnectLoop(signal: AbortSignal): Promise { for ( let attempt = 0; diff --git a/apps/desktop/src/main/lib/workspace-runtime/remote-ssh/terminal-runtime.ts b/apps/desktop/src/main/lib/workspace-runtime/remote-ssh/terminal-runtime.ts index 01d37fe7bc0..b37af91de22 100644 --- a/apps/desktop/src/main/lib/workspace-runtime/remote-ssh/terminal-runtime.ts +++ b/apps/desktop/src/main/lib/workspace-runtime/remote-ssh/terminal-runtime.ts @@ -117,6 +117,12 @@ export class RemoteSSHTerminalRuntime initialCommands, } = params; + // Ensure SSH is connected before trying to open a shell channel. + // Registry warmup is async, so attach can race ahead on first mount. + if (!this.connection.isConnected) { + await this.connection.connect(); + } + // Try to reattach to an existing alive session (e.g. after navigation away/back) const existing = this.sessions.get(paneId); if (existing?.isAlive) { diff --git a/apps/desktop/src/renderer/components/NewWorkspaceModal/NewWorkspaceModal.tsx b/apps/desktop/src/renderer/components/NewWorkspaceModal/NewWorkspaceModal.tsx index 273db064950..c16c76cbb13 100644 --- a/apps/desktop/src/renderer/components/NewWorkspaceModal/NewWorkspaceModal.tsx +++ b/apps/desktop/src/renderer/components/NewWorkspaceModal/NewWorkspaceModal.tsx @@ -622,6 +622,7 @@ function RemoteWorkspaceForm({ onSuccess: () => void; }) { const navigate = useNavigate(); + const utils = electronTrpc.useUtils(); const [sshConnectionId, setSshConnectionId] = useState(""); const [remotePath, setRemotePath] = useState(""); const [workspaceName, setWorkspaceName] = useState(""); @@ -633,6 +634,7 @@ function RemoteWorkspaceForm({ electronTrpc.workspaces.createRemoteWorkspace.useMutation({ onSuccess: (data) => { toast.success("Remote workspace created"); + utils.workspaces.getAllGrouped.invalidate(); onSuccess(); navigateToWorkspace(data.workspace.id, navigate, { replace: true }); }, diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/workspace/$workspaceId/page.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/workspace/$workspaceId/page.tsx index 778bb54d567..1d70919bff4 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/workspace/$workspaceId/page.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/workspace/$workspaceId/page.tsx @@ -326,7 +326,10 @@ function WorkspacePage() { if (pr?.url) { window.open(pr.url, "_blank"); } else if (workspace?.worktreePath) { - createPRMutation.mutate({ worktreePath: workspace.worktreePath }); + createPRMutation.mutate({ + worktreePath: workspace.worktreePath, + workspaceId, + }); } }, undefined, diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ChangesContent/ChangesContent.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ChangesContent/ChangesContent.tsx index ad11d5d024e..bfdc0178785 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ChangesContent/ChangesContent.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ChangesContent/ChangesContent.tsx @@ -13,6 +13,7 @@ export function ChangesContent() { const { status, isLoading, effectiveBaseBranch } = useGitChangesStatus({ worktreePath, + workspaceId, refetchInterval: 2500, refetchOnWindowFocus: true, }); @@ -47,6 +48,7 @@ export function ChangesContent() { status={status} worktreePath={worktreePath} baseBranch={effectiveBaseBranch} + workspaceId={workspaceId} /> ); diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ChangesContent/components/FileDiffSection/FileDiffSection.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ChangesContent/components/FileDiffSection/FileDiffSection.tsx index ccadcc611c0..918f58b3f3b 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ChangesContent/components/FileDiffSection/FileDiffSection.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ChangesContent/components/FileDiffSection/FileDiffSection.tsx @@ -20,6 +20,7 @@ interface FileDiffSectionProps { category: ChangeCategory; commitHash?: string; worktreePath: string; + workspaceId?: string; baseBranch?: string; isExpanded: boolean; onToggleExpanded: () => void; @@ -63,6 +64,7 @@ export function FileDiffSection({ category, commitHash, worktreePath, + workspaceId, baseBranch, isExpanded, onToggleExpanded, @@ -89,6 +91,7 @@ export function FileDiffSection({ category, worktreePath, filePath: file.path, + workspaceId, }); const totalChanges = file.additions + file.deletions; @@ -204,6 +207,7 @@ export function FileDiffSection({ category, commitHash, defaultBranch: category === "against-base" ? baseBranch : undefined, + workspaceId, }, { enabled: diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ChangesContent/components/FileDiffSection/hooks/useFileDiffEdit/useFileDiffEdit.ts b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ChangesContent/components/FileDiffSection/hooks/useFileDiffEdit/useFileDiffEdit.ts index 9035a13b416..8173c198181 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ChangesContent/components/FileDiffSection/hooks/useFileDiffEdit/useFileDiffEdit.ts +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ChangesContent/components/FileDiffSection/hooks/useFileDiffEdit/useFileDiffEdit.ts @@ -7,12 +7,14 @@ interface UseFileDiffEditParams { category: ChangeCategory; worktreePath: string; filePath: string; + workspaceId?: string; } export function useFileDiffEdit({ category, worktreePath, filePath, + workspaceId, }: UseFileDiffEditParams) { const [isEditing, setIsEditing] = useState(false); const editable = isDiffEditable(category); @@ -28,9 +30,9 @@ export function useFileDiffEdit({ const handleSave = useCallback( (content: string) => { if (!worktreePath || !filePath) return; - saveFileMutation.mutate({ worktreePath, filePath, content }); + saveFileMutation.mutate({ worktreePath, filePath, content, workspaceId }); }, - [worktreePath, filePath, saveFileMutation], + [worktreePath, filePath, workspaceId, saveFileMutation], ); const toggleEdit = editable ? () => setIsEditing((prev) => !prev) : undefined; diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ChangesContent/components/InfiniteScrollView/InfiniteScrollView.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ChangesContent/components/InfiniteScrollView/InfiniteScrollView.tsx index d3d6ec86798..2b0fef1d6fa 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ChangesContent/components/InfiniteScrollView/InfiniteScrollView.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ChangesContent/components/InfiniteScrollView/InfiniteScrollView.tsx @@ -15,12 +15,14 @@ interface InfiniteScrollViewProps { status: GitChangesStatus; worktreePath: string; baseBranch: string; + workspaceId?: string; } export function InfiniteScrollView({ status, worktreePath, baseBranch, + workspaceId, }: InfiniteScrollViewProps) { const { containerRef, viewedCount } = useScrollContext(); const { @@ -35,7 +37,7 @@ export function InfiniteScrollView({ const [collapsedFiles, setCollapsedFiles] = useState>(new Set()); const { stageFileMutation, unstageFileMutation, handleDiscard, isActioning } = - useFileMutations({ worktreePath, baseBranch }); + useFileMutations({ worktreePath, baseBranch, workspaceId }); const totals = useMemo(() => { const allFiles = [ @@ -169,6 +171,7 @@ export function InfiniteScrollView({ category={focusedEntry.category} commitHash={focusedEntry.commitHash} worktreePath={worktreePath} + workspaceId={workspaceId} baseBranch={ focusedEntry.category === "against-base" ? baseBranch : undefined } @@ -193,6 +196,7 @@ export function InfiniteScrollView({ files={sortedAgainstBase} category="against-base" worktreePath={worktreePath} + workspaceId={workspaceId} baseBranch={baseBranch} collapsedFiles={collapsedFiles} onToggleFile={toggleFile} @@ -217,6 +221,7 @@ export function InfiniteScrollView({ key={commit.hash} commit={commit} worktreePath={worktreePath} + workspaceId={workspaceId} collapsedFiles={collapsedFiles} onToggleFile={toggleFile} scrollElementRef={containerRef} @@ -247,6 +252,7 @@ export function InfiniteScrollView({ unstageFileMutation.mutate({ worktreePath, filePath: file.path, + workspaceId, }) } onDiscard={handleDiscard} @@ -276,6 +282,7 @@ export function InfiniteScrollView({ stageFileMutation.mutate({ worktreePath, filePath: file.path, + workspaceId, }) } onDiscard={handleDiscard} diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ChangesContent/components/InfiniteScrollView/components/CommitSection/CommitSection.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ChangesContent/components/InfiniteScrollView/components/CommitSection/CommitSection.tsx index 584a14cad7d..9fd18acc919 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ChangesContent/components/InfiniteScrollView/components/CommitSection/CommitSection.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ChangesContent/components/InfiniteScrollView/components/CommitSection/CommitSection.tsx @@ -7,6 +7,7 @@ import { VirtualizedFileList } from "../../../VirtualizedFileList"; interface CommitSectionProps { commit: CommitInfo; worktreePath: string; + workspaceId?: string; collapsedFiles: Set; onToggleFile: (key: string) => void; scrollElementRef: RefObject; @@ -15,6 +16,7 @@ interface CommitSectionProps { export function CommitSection({ commit, worktreePath, + workspaceId, collapsedFiles, onToggleFile, scrollElementRef, @@ -25,6 +27,7 @@ export function CommitSection({ { worktreePath, commitHash: commit.hash, + workspaceId, }, { enabled: isCommitExpanded }, ); @@ -58,6 +61,7 @@ export function CommitSection({ category="committed" commitHash={commit.hash} worktreePath={worktreePath} + workspaceId={workspaceId} collapsedFiles={collapsedFiles} onToggleFile={onToggleFile} scrollElementRef={scrollElementRef} diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ChangesContent/components/InfiniteScrollView/hooks/useFileMutations/useFileMutations.ts b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ChangesContent/components/InfiniteScrollView/hooks/useFileMutations/useFileMutations.ts index 2a2ac325337..05e4c4c6279 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ChangesContent/components/InfiniteScrollView/hooks/useFileMutations/useFileMutations.ts +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ChangesContent/components/InfiniteScrollView/hooks/useFileMutations/useFileMutations.ts @@ -6,9 +6,11 @@ import type { ChangedFile } from "shared/changes-types"; export function useFileMutations({ worktreePath, baseBranch, + workspaceId, }: { worktreePath: string; baseBranch: string; + workspaceId?: string; }) { const trpcUtils = electronTrpc.useUtils(); const refetch = useCallback(() => { @@ -70,15 +72,22 @@ export function useFileMutations({ deleteUntrackedMutation.mutate({ worktreePath, filePath: file.path, + workspaceId, }); } else { discardChangesMutation.mutate({ worktreePath, filePath: file.path, + workspaceId, }); } }, - [worktreePath, deleteUntrackedMutation, discardChangesMutation], + [ + worktreePath, + workspaceId, + deleteUntrackedMutation, + discardChangesMutation, + ], ); const isActioning = diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ChangesContent/components/VirtualizedFileList/VirtualizedFileList.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ChangesContent/components/VirtualizedFileList/VirtualizedFileList.tsx index 5c1b495b7bb..c6d9848ccdc 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ChangesContent/components/VirtualizedFileList/VirtualizedFileList.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ChangesContent/components/VirtualizedFileList/VirtualizedFileList.tsx @@ -8,6 +8,7 @@ interface VirtualizedFileListProps { category: ChangeCategory; commitHash?: string; worktreePath: string; + workspaceId?: string; baseBranch?: string; collapsedFiles: Set; onToggleFile: (key: string) => void; @@ -26,6 +27,7 @@ export function VirtualizedFileList({ category, commitHash, worktreePath, + workspaceId, baseBranch, collapsedFiles, onToggleFile, @@ -75,6 +77,7 @@ export function VirtualizedFileList({ category={category} commitHash={commitHash} worktreePath={worktreePath} + workspaceId={workspaceId} baseBranch={baseBranch} isExpanded={!collapsedFiles.has(fileKey)} onToggleExpanded={() => onToggleFile(fileKey)} diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/FileViewerPane/FileViewerPane.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/FileViewerPane/FileViewerPane.tsx index 0305fd35392..de37f48c0c1 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/FileViewerPane/FileViewerPane.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/FileViewerPane/FileViewerPane.tsx @@ -1,3 +1,4 @@ +import { useParams } from "@tanstack/react-router"; import type * as Monaco from "monaco-editor"; import { useCallback, useEffect, useRef, useState } from "react"; import type { MosaicBranch } from "react-mosaic-component"; @@ -58,6 +59,7 @@ export function FileViewerPane({ onMoveToTab, onMoveToNewTab, }: FileViewerPaneProps) { + const { workspaceId } = useParams({ strict: false }); // Use granular selector to only get this pane's fileViewer data const fileViewer = useTabsStore((s) => s.panes[paneId]?.fileViewer); const { @@ -97,6 +99,7 @@ export function FileViewerPane({ originalDiffContentRef, draftContentRef, setIsDirty, + workspaceId, }); const { @@ -116,6 +119,7 @@ export function FileViewerPane({ isDirty, originalContentRef, originalDiffContentRef, + workspaceId, }); const handleEditorChange = useCallback((value: string | undefined) => { diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/FileViewerPane/hooks/useFileContent/useFileContent.ts b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/FileViewerPane/hooks/useFileContent/useFileContent.ts index 8544ce304d9..4b479aaad1d 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/FileViewerPane/hooks/useFileContent/useFileContent.ts +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/FileViewerPane/hooks/useFileContent/useFileContent.ts @@ -13,6 +13,7 @@ interface UseFileContentParams { isDirty: boolean; originalContentRef: React.MutableRefObject; originalDiffContentRef: React.MutableRefObject; + workspaceId?: string; } export function useFileContent({ @@ -25,9 +26,10 @@ export function useFileContent({ isDirty, originalContentRef, originalDiffContentRef, + workspaceId, }: UseFileContentParams) { const { data: branchData } = electronTrpc.changes.getBranches.useQuery( - { worktreePath }, + { worktreePath, workspaceId }, { enabled: !!worktreePath && diffCategory === "against-base" }, ); const effectiveBaseBranch = @@ -37,7 +39,7 @@ export function useFileContent({ const { data: rawFileData, isLoading: isLoadingRaw } = electronTrpc.changes.readWorkingFile.useQuery( - { worktreePath, filePath }, + { worktreePath, filePath, workspaceId }, { enabled: viewMode !== "diff" && !isImage && !!filePath && !!worktreePath, @@ -46,7 +48,7 @@ export function useFileContent({ const { data: imageData, isLoading: isLoadingImage } = electronTrpc.changes.readWorkingFileImage.useQuery( - { worktreePath, filePath }, + { worktreePath, filePath, workspaceId }, { enabled: viewMode === "rendered" && isImage && !!filePath && !!worktreePath, @@ -63,6 +65,7 @@ export function useFileContent({ commitHash, defaultBranch: diffCategory === "against-base" ? effectiveBaseBranch : undefined, + workspaceId, }, { enabled: diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/FileViewerPane/hooks/useFileSave/useFileSave.ts b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/FileViewerPane/hooks/useFileSave/useFileSave.ts index 2473264172b..ea0afe0e03c 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/FileViewerPane/hooks/useFileSave/useFileSave.ts +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/FileViewerPane/hooks/useFileSave/useFileSave.ts @@ -14,6 +14,7 @@ interface UseFileSaveParams { originalDiffContentRef: MutableRefObject; draftContentRef: MutableRefObject; setIsDirty: (dirty: boolean) => void; + workspaceId?: string; } export function useFileSave({ @@ -26,6 +27,7 @@ export function useFileSave({ originalDiffContentRef, draftContentRef, setIsDirty, + workspaceId, }: UseFileSaveParams) { const savingFromRawRef = useRef(false); const savingDiffContentRef = useRef(null); @@ -78,8 +80,9 @@ export function useFileSave({ worktreePath, filePath, content: editorRef.current.getValue(), + workspaceId, }); - }, [worktreePath, filePath, saveFileMutation, editorRef]); + }, [worktreePath, filePath, workspaceId, saveFileMutation, editorRef]); const handleSaveDiff = useCallback( async (content: string) => { @@ -90,9 +93,10 @@ export function useFileSave({ worktreePath, filePath, content, + workspaceId, }); }, - [worktreePath, filePath, saveFileMutation], + [worktreePath, filePath, workspaceId, saveFileMutation], ); return { diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/RightSidebar/ChangesView/ChangesView.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/RightSidebar/ChangesView/ChangesView.tsx index 31fc7b58e30..5a10c2691e5 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/RightSidebar/ChangesView/ChangesView.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/RightSidebar/ChangesView/ChangesView.tsx @@ -44,6 +44,7 @@ export function ChangesView({ onFileOpen, isExpandedView }: ChangesViewProps) { const { status, isLoading, effectiveBaseBranch, refetch } = useGitChangesStatus({ worktreePath, + workspaceId, refetchInterval: 2500, refetchOnWindowFocus: true, }); @@ -189,11 +190,13 @@ export function ChangesView({ onFileOpen, isExpandedView }: ChangesViewProps) { deleteUntrackedMutation.mutate({ worktreePath, filePath: file.path, + workspaceId, }); } else { discardChangesMutation.mutate({ worktreePath, filePath: file.path, + workspaceId, }); } }; @@ -225,6 +228,7 @@ export function ChangesView({ onFileOpen, isExpandedView }: ChangesViewProps) { t.changes.getCommitFiles({ worktreePath: worktreePath || "", commitHash: hash, + workspaceId, }), ), ); @@ -324,11 +328,16 @@ export function ChangesView({ onFileOpen, isExpandedView }: ChangesViewProps) { onViewModeChange={setFileListViewMode} worktreePath={worktreePath} workspaceId={workspaceId} - onStash={() => stashMutation.mutate({ worktreePath })} + onStash={() => stashMutation.mutate({ worktreePath, workspaceId })} onStashIncludeUntracked={() => - stashIncludeUntrackedMutation.mutate({ worktreePath }) + stashIncludeUntrackedMutation.mutate({ + worktreePath, + workspaceId, + }) + } + onStashPop={() => + stashPopMutation.mutate({ worktreePath, workspaceId }) } - onStashPop={() => stashPopMutation.mutate({ worktreePath })} isStashPending={ stashMutation.isPending || stashIncludeUntrackedMutation.isPending || @@ -338,6 +347,7 @@ export function ChangesView({ onFileOpen, isExpandedView }: ChangesViewProps) { unstageAllMutation.mutate({ worktreePath: worktreePath || "", + workspaceId, }) } disabled={unstageAllMutation.isPending} @@ -447,6 +458,7 @@ export function ChangesView({ onFileOpen, isExpandedView }: ChangesViewProps) { unstageFileMutation.mutate({ worktreePath: worktreePath || "", filePath: file.path, + workspaceId, }) } isActioning={unstageFileMutation.isPending} @@ -488,6 +500,7 @@ export function ChangesView({ onFileOpen, isExpandedView }: ChangesViewProps) { onClick={() => stageAllMutation.mutate({ worktreePath: worktreePath || "", + workspaceId, }) } disabled={stageAllMutation.isPending} @@ -510,6 +523,7 @@ export function ChangesView({ onFileOpen, isExpandedView }: ChangesViewProps) { stageFileMutation.mutate({ worktreePath: worktreePath || "", filePath: file.path, + workspaceId, }) } isActioning={ @@ -557,6 +571,7 @@ export function ChangesView({ onFileOpen, isExpandedView }: ChangesViewProps) { setShowDiscardUnstagedDialog(false); discardAllUnstagedMutation.mutate({ worktreePath: worktreePath || "", + workspaceId, }); }} > @@ -597,6 +612,7 @@ export function ChangesView({ onFileOpen, isExpandedView }: ChangesViewProps) { setShowDiscardStagedDialog(false); discardAllStagedMutation.mutate({ worktreePath: worktreePath || "", + workspaceId, }); }} > diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/RightSidebar/ChangesView/components/ChangesHeader/ChangesHeader.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/RightSidebar/ChangesView/components/ChangesHeader/ChangesHeader.tsx index 3752f09fe4d..9aacfa4f33c 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/RightSidebar/ChangesView/components/ChangesHeader/ChangesHeader.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/RightSidebar/ChangesView/components/ChangesHeader/ChangesHeader.tsx @@ -36,13 +36,19 @@ interface ChangesHeaderProps { isStashPending: boolean; } -function BaseBranchSelector({ worktreePath }: { worktreePath: string }) { +function BaseBranchSelector({ + worktreePath, + workspaceId, +}: { + worktreePath: string; + workspaceId?: string; +}) { const [open, setOpen] = useState(false); const [search, setSearch] = useState(""); const utils = electronTrpc.useUtils(); const { data: branchData, isLoading } = electronTrpc.changes.getBranches.useQuery( - { worktreePath }, + { worktreePath, workspaceId }, { enabled: !!worktreePath }, ); @@ -76,6 +82,7 @@ function BaseBranchSelector({ worktreePath }: { worktreePath: string }) { updateBaseBranch.mutate({ worktreePath, baseBranch: branch === branchData?.defaultBranch ? null : branch, + workspaceId, }); setOpen(false); setSearch(""); @@ -237,7 +244,10 @@ export function ChangesHeader({ }: ChangesHeaderProps) { return (
- + - mergePRMutation.mutate({ worktreePath, strategy }); + mergePRMutation.mutate({ worktreePath, strategy, workspaceId }); if (isLoading) { return ( @@ -62,7 +62,9 @@ export function PRButton({
); diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ChangesContent/components/FileDiffSection/FileDiffSection.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ChangesContent/components/FileDiffSection/FileDiffSection.tsx index 918f58b3f3b..ccadcc611c0 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ChangesContent/components/FileDiffSection/FileDiffSection.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ChangesContent/components/FileDiffSection/FileDiffSection.tsx @@ -20,7 +20,6 @@ interface FileDiffSectionProps { category: ChangeCategory; commitHash?: string; worktreePath: string; - workspaceId?: string; baseBranch?: string; isExpanded: boolean; onToggleExpanded: () => void; @@ -64,7 +63,6 @@ export function FileDiffSection({ category, commitHash, worktreePath, - workspaceId, baseBranch, isExpanded, onToggleExpanded, @@ -91,7 +89,6 @@ export function FileDiffSection({ category, worktreePath, filePath: file.path, - workspaceId, }); const totalChanges = file.additions + file.deletions; @@ -207,7 +204,6 @@ export function FileDiffSection({ category, commitHash, defaultBranch: category === "against-base" ? baseBranch : undefined, - workspaceId, }, { enabled: diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ChangesContent/components/FileDiffSection/hooks/useFileDiffEdit/useFileDiffEdit.ts b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ChangesContent/components/FileDiffSection/hooks/useFileDiffEdit/useFileDiffEdit.ts index 8173c198181..9035a13b416 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ChangesContent/components/FileDiffSection/hooks/useFileDiffEdit/useFileDiffEdit.ts +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ChangesContent/components/FileDiffSection/hooks/useFileDiffEdit/useFileDiffEdit.ts @@ -7,14 +7,12 @@ interface UseFileDiffEditParams { category: ChangeCategory; worktreePath: string; filePath: string; - workspaceId?: string; } export function useFileDiffEdit({ category, worktreePath, filePath, - workspaceId, }: UseFileDiffEditParams) { const [isEditing, setIsEditing] = useState(false); const editable = isDiffEditable(category); @@ -30,9 +28,9 @@ export function useFileDiffEdit({ const handleSave = useCallback( (content: string) => { if (!worktreePath || !filePath) return; - saveFileMutation.mutate({ worktreePath, filePath, content, workspaceId }); + saveFileMutation.mutate({ worktreePath, filePath, content }); }, - [worktreePath, filePath, workspaceId, saveFileMutation], + [worktreePath, filePath, saveFileMutation], ); const toggleEdit = editable ? () => setIsEditing((prev) => !prev) : undefined; diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ChangesContent/components/InfiniteScrollView/InfiniteScrollView.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ChangesContent/components/InfiniteScrollView/InfiniteScrollView.tsx index 2b0fef1d6fa..d3d6ec86798 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ChangesContent/components/InfiniteScrollView/InfiniteScrollView.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ChangesContent/components/InfiniteScrollView/InfiniteScrollView.tsx @@ -15,14 +15,12 @@ interface InfiniteScrollViewProps { status: GitChangesStatus; worktreePath: string; baseBranch: string; - workspaceId?: string; } export function InfiniteScrollView({ status, worktreePath, baseBranch, - workspaceId, }: InfiniteScrollViewProps) { const { containerRef, viewedCount } = useScrollContext(); const { @@ -37,7 +35,7 @@ export function InfiniteScrollView({ const [collapsedFiles, setCollapsedFiles] = useState>(new Set()); const { stageFileMutation, unstageFileMutation, handleDiscard, isActioning } = - useFileMutations({ worktreePath, baseBranch, workspaceId }); + useFileMutations({ worktreePath, baseBranch }); const totals = useMemo(() => { const allFiles = [ @@ -171,7 +169,6 @@ export function InfiniteScrollView({ category={focusedEntry.category} commitHash={focusedEntry.commitHash} worktreePath={worktreePath} - workspaceId={workspaceId} baseBranch={ focusedEntry.category === "against-base" ? baseBranch : undefined } @@ -196,7 +193,6 @@ export function InfiniteScrollView({ files={sortedAgainstBase} category="against-base" worktreePath={worktreePath} - workspaceId={workspaceId} baseBranch={baseBranch} collapsedFiles={collapsedFiles} onToggleFile={toggleFile} @@ -221,7 +217,6 @@ export function InfiniteScrollView({ key={commit.hash} commit={commit} worktreePath={worktreePath} - workspaceId={workspaceId} collapsedFiles={collapsedFiles} onToggleFile={toggleFile} scrollElementRef={containerRef} @@ -252,7 +247,6 @@ export function InfiniteScrollView({ unstageFileMutation.mutate({ worktreePath, filePath: file.path, - workspaceId, }) } onDiscard={handleDiscard} @@ -282,7 +276,6 @@ export function InfiniteScrollView({ stageFileMutation.mutate({ worktreePath, filePath: file.path, - workspaceId, }) } onDiscard={handleDiscard} diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ChangesContent/components/InfiniteScrollView/components/CommitSection/CommitSection.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ChangesContent/components/InfiniteScrollView/components/CommitSection/CommitSection.tsx index 9fd18acc919..584a14cad7d 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ChangesContent/components/InfiniteScrollView/components/CommitSection/CommitSection.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ChangesContent/components/InfiniteScrollView/components/CommitSection/CommitSection.tsx @@ -7,7 +7,6 @@ import { VirtualizedFileList } from "../../../VirtualizedFileList"; interface CommitSectionProps { commit: CommitInfo; worktreePath: string; - workspaceId?: string; collapsedFiles: Set; onToggleFile: (key: string) => void; scrollElementRef: RefObject; @@ -16,7 +15,6 @@ interface CommitSectionProps { export function CommitSection({ commit, worktreePath, - workspaceId, collapsedFiles, onToggleFile, scrollElementRef, @@ -27,7 +25,6 @@ export function CommitSection({ { worktreePath, commitHash: commit.hash, - workspaceId, }, { enabled: isCommitExpanded }, ); @@ -61,7 +58,6 @@ export function CommitSection({ category="committed" commitHash={commit.hash} worktreePath={worktreePath} - workspaceId={workspaceId} collapsedFiles={collapsedFiles} onToggleFile={onToggleFile} scrollElementRef={scrollElementRef} diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ChangesContent/components/InfiniteScrollView/hooks/useFileMutations/useFileMutations.ts b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ChangesContent/components/InfiniteScrollView/hooks/useFileMutations/useFileMutations.ts index 05e4c4c6279..2a2ac325337 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ChangesContent/components/InfiniteScrollView/hooks/useFileMutations/useFileMutations.ts +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ChangesContent/components/InfiniteScrollView/hooks/useFileMutations/useFileMutations.ts @@ -6,11 +6,9 @@ import type { ChangedFile } from "shared/changes-types"; export function useFileMutations({ worktreePath, baseBranch, - workspaceId, }: { worktreePath: string; baseBranch: string; - workspaceId?: string; }) { const trpcUtils = electronTrpc.useUtils(); const refetch = useCallback(() => { @@ -72,22 +70,15 @@ export function useFileMutations({ deleteUntrackedMutation.mutate({ worktreePath, filePath: file.path, - workspaceId, }); } else { discardChangesMutation.mutate({ worktreePath, filePath: file.path, - workspaceId, }); } }, - [ - worktreePath, - workspaceId, - deleteUntrackedMutation, - discardChangesMutation, - ], + [worktreePath, deleteUntrackedMutation, discardChangesMutation], ); const isActioning = diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ChangesContent/components/VirtualizedFileList/VirtualizedFileList.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ChangesContent/components/VirtualizedFileList/VirtualizedFileList.tsx index c6d9848ccdc..5c1b495b7bb 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ChangesContent/components/VirtualizedFileList/VirtualizedFileList.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ChangesContent/components/VirtualizedFileList/VirtualizedFileList.tsx @@ -8,7 +8,6 @@ interface VirtualizedFileListProps { category: ChangeCategory; commitHash?: string; worktreePath: string; - workspaceId?: string; baseBranch?: string; collapsedFiles: Set; onToggleFile: (key: string) => void; @@ -27,7 +26,6 @@ export function VirtualizedFileList({ category, commitHash, worktreePath, - workspaceId, baseBranch, collapsedFiles, onToggleFile, @@ -77,7 +75,6 @@ export function VirtualizedFileList({ category={category} commitHash={commitHash} worktreePath={worktreePath} - workspaceId={workspaceId} baseBranch={baseBranch} isExpanded={!collapsedFiles.has(fileKey)} onToggleExpanded={() => onToggleFile(fileKey)} diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/FileViewerPane/FileViewerPane.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/FileViewerPane/FileViewerPane.tsx index de37f48c0c1..0305fd35392 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/FileViewerPane/FileViewerPane.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/FileViewerPane/FileViewerPane.tsx @@ -1,4 +1,3 @@ -import { useParams } from "@tanstack/react-router"; import type * as Monaco from "monaco-editor"; import { useCallback, useEffect, useRef, useState } from "react"; import type { MosaicBranch } from "react-mosaic-component"; @@ -59,7 +58,6 @@ export function FileViewerPane({ onMoveToTab, onMoveToNewTab, }: FileViewerPaneProps) { - const { workspaceId } = useParams({ strict: false }); // Use granular selector to only get this pane's fileViewer data const fileViewer = useTabsStore((s) => s.panes[paneId]?.fileViewer); const { @@ -99,7 +97,6 @@ export function FileViewerPane({ originalDiffContentRef, draftContentRef, setIsDirty, - workspaceId, }); const { @@ -119,7 +116,6 @@ export function FileViewerPane({ isDirty, originalContentRef, originalDiffContentRef, - workspaceId, }); const handleEditorChange = useCallback((value: string | undefined) => { diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/FileViewerPane/hooks/useFileContent/useFileContent.ts b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/FileViewerPane/hooks/useFileContent/useFileContent.ts index 4b479aaad1d..8544ce304d9 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/FileViewerPane/hooks/useFileContent/useFileContent.ts +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/FileViewerPane/hooks/useFileContent/useFileContent.ts @@ -13,7 +13,6 @@ interface UseFileContentParams { isDirty: boolean; originalContentRef: React.MutableRefObject; originalDiffContentRef: React.MutableRefObject; - workspaceId?: string; } export function useFileContent({ @@ -26,10 +25,9 @@ export function useFileContent({ isDirty, originalContentRef, originalDiffContentRef, - workspaceId, }: UseFileContentParams) { const { data: branchData } = electronTrpc.changes.getBranches.useQuery( - { worktreePath, workspaceId }, + { worktreePath }, { enabled: !!worktreePath && diffCategory === "against-base" }, ); const effectiveBaseBranch = @@ -39,7 +37,7 @@ export function useFileContent({ const { data: rawFileData, isLoading: isLoadingRaw } = electronTrpc.changes.readWorkingFile.useQuery( - { worktreePath, filePath, workspaceId }, + { worktreePath, filePath }, { enabled: viewMode !== "diff" && !isImage && !!filePath && !!worktreePath, @@ -48,7 +46,7 @@ export function useFileContent({ const { data: imageData, isLoading: isLoadingImage } = electronTrpc.changes.readWorkingFileImage.useQuery( - { worktreePath, filePath, workspaceId }, + { worktreePath, filePath }, { enabled: viewMode === "rendered" && isImage && !!filePath && !!worktreePath, @@ -65,7 +63,6 @@ export function useFileContent({ commitHash, defaultBranch: diffCategory === "against-base" ? effectiveBaseBranch : undefined, - workspaceId, }, { enabled: diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/FileViewerPane/hooks/useFileSave/useFileSave.ts b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/FileViewerPane/hooks/useFileSave/useFileSave.ts index ea0afe0e03c..2473264172b 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/FileViewerPane/hooks/useFileSave/useFileSave.ts +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/FileViewerPane/hooks/useFileSave/useFileSave.ts @@ -14,7 +14,6 @@ interface UseFileSaveParams { originalDiffContentRef: MutableRefObject; draftContentRef: MutableRefObject; setIsDirty: (dirty: boolean) => void; - workspaceId?: string; } export function useFileSave({ @@ -27,7 +26,6 @@ export function useFileSave({ originalDiffContentRef, draftContentRef, setIsDirty, - workspaceId, }: UseFileSaveParams) { const savingFromRawRef = useRef(false); const savingDiffContentRef = useRef(null); @@ -80,9 +78,8 @@ export function useFileSave({ worktreePath, filePath, content: editorRef.current.getValue(), - workspaceId, }); - }, [worktreePath, filePath, workspaceId, saveFileMutation, editorRef]); + }, [worktreePath, filePath, saveFileMutation, editorRef]); const handleSaveDiff = useCallback( async (content: string) => { @@ -93,10 +90,9 @@ export function useFileSave({ worktreePath, filePath, content, - workspaceId, }); }, - [worktreePath, filePath, workspaceId, saveFileMutation], + [worktreePath, filePath, saveFileMutation], ); return { diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/RightSidebar/ChangesView/ChangesView.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/RightSidebar/ChangesView/ChangesView.tsx index 5a10c2691e5..31fc7b58e30 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/RightSidebar/ChangesView/ChangesView.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/RightSidebar/ChangesView/ChangesView.tsx @@ -44,7 +44,6 @@ export function ChangesView({ onFileOpen, isExpandedView }: ChangesViewProps) { const { status, isLoading, effectiveBaseBranch, refetch } = useGitChangesStatus({ worktreePath, - workspaceId, refetchInterval: 2500, refetchOnWindowFocus: true, }); @@ -190,13 +189,11 @@ export function ChangesView({ onFileOpen, isExpandedView }: ChangesViewProps) { deleteUntrackedMutation.mutate({ worktreePath, filePath: file.path, - workspaceId, }); } else { discardChangesMutation.mutate({ worktreePath, filePath: file.path, - workspaceId, }); } }; @@ -228,7 +225,6 @@ export function ChangesView({ onFileOpen, isExpandedView }: ChangesViewProps) { t.changes.getCommitFiles({ worktreePath: worktreePath || "", commitHash: hash, - workspaceId, }), ), ); @@ -328,16 +324,11 @@ export function ChangesView({ onFileOpen, isExpandedView }: ChangesViewProps) { onViewModeChange={setFileListViewMode} worktreePath={worktreePath} workspaceId={workspaceId} - onStash={() => stashMutation.mutate({ worktreePath, workspaceId })} + onStash={() => stashMutation.mutate({ worktreePath })} onStashIncludeUntracked={() => - stashIncludeUntrackedMutation.mutate({ - worktreePath, - workspaceId, - }) - } - onStashPop={() => - stashPopMutation.mutate({ worktreePath, workspaceId }) + stashIncludeUntrackedMutation.mutate({ worktreePath }) } + onStashPop={() => stashPopMutation.mutate({ worktreePath })} isStashPending={ stashMutation.isPending || stashIncludeUntrackedMutation.isPending || @@ -347,7 +338,6 @@ export function ChangesView({ onFileOpen, isExpandedView }: ChangesViewProps) { unstageAllMutation.mutate({ worktreePath: worktreePath || "", - workspaceId, }) } disabled={unstageAllMutation.isPending} @@ -458,7 +447,6 @@ export function ChangesView({ onFileOpen, isExpandedView }: ChangesViewProps) { unstageFileMutation.mutate({ worktreePath: worktreePath || "", filePath: file.path, - workspaceId, }) } isActioning={unstageFileMutation.isPending} @@ -500,7 +488,6 @@ export function ChangesView({ onFileOpen, isExpandedView }: ChangesViewProps) { onClick={() => stageAllMutation.mutate({ worktreePath: worktreePath || "", - workspaceId, }) } disabled={stageAllMutation.isPending} @@ -523,7 +510,6 @@ export function ChangesView({ onFileOpen, isExpandedView }: ChangesViewProps) { stageFileMutation.mutate({ worktreePath: worktreePath || "", filePath: file.path, - workspaceId, }) } isActioning={ @@ -571,7 +557,6 @@ export function ChangesView({ onFileOpen, isExpandedView }: ChangesViewProps) { setShowDiscardUnstagedDialog(false); discardAllUnstagedMutation.mutate({ worktreePath: worktreePath || "", - workspaceId, }); }} > @@ -612,7 +597,6 @@ export function ChangesView({ onFileOpen, isExpandedView }: ChangesViewProps) { setShowDiscardStagedDialog(false); discardAllStagedMutation.mutate({ worktreePath: worktreePath || "", - workspaceId, }); }} > diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/RightSidebar/ChangesView/components/ChangesHeader/ChangesHeader.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/RightSidebar/ChangesView/components/ChangesHeader/ChangesHeader.tsx index 9aacfa4f33c..3752f09fe4d 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/RightSidebar/ChangesView/components/ChangesHeader/ChangesHeader.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/RightSidebar/ChangesView/components/ChangesHeader/ChangesHeader.tsx @@ -36,19 +36,13 @@ interface ChangesHeaderProps { isStashPending: boolean; } -function BaseBranchSelector({ - worktreePath, - workspaceId, -}: { - worktreePath: string; - workspaceId?: string; -}) { +function BaseBranchSelector({ worktreePath }: { worktreePath: string }) { const [open, setOpen] = useState(false); const [search, setSearch] = useState(""); const utils = electronTrpc.useUtils(); const { data: branchData, isLoading } = electronTrpc.changes.getBranches.useQuery( - { worktreePath, workspaceId }, + { worktreePath }, { enabled: !!worktreePath }, ); @@ -82,7 +76,6 @@ function BaseBranchSelector({ updateBaseBranch.mutate({ worktreePath, baseBranch: branch === branchData?.defaultBranch ? null : branch, - workspaceId, }); setOpen(false); setSearch(""); @@ -244,10 +237,7 @@ export function ChangesHeader({ }: ChangesHeaderProps) { return (
- + - mergePRMutation.mutate({ worktreePath, strategy, workspaceId }); + mergePRMutation.mutate({ worktreePath, strategy }); if (isLoading) { return ( @@ -62,9 +62,7 @@ export function PRButton({