Skip to content

Commit d39290f

Browse files
authored
feat(desktop): add SSH server connections (#47753)
1 parent 6ee2ed7 commit d39290f

79 files changed

Lines changed: 4467 additions & 114 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
import { expect, story } from "../../storybook/playwright/story"
2+
3+
story("settings menu reconnect retains its prompt handler across server updates", async ({ mount, page }) => {
4+
const component = await mount("app-dialog-ssh--settings-reconnect")
5+
await component.getByRole("button", { name: "More options" }).click()
6+
await page.getByRole("menuitem", { name: "Connect", exact: true }).click()
7+
const dialog = page.getByRole("dialog")
8+
await expect(dialog.getByRole("textbox")).toBeVisible()
9+
await dialog.getByRole("textbox").fill("fixture-password")
10+
await dialog.getByRole("button", { name: "Continue" }).click()
11+
await expect(dialog.getByRole("textbox", { name: "Verification code:" })).toBeVisible()
12+
await dialog.getByRole("textbox").fill("123456")
13+
await dialog.getByRole("button", { name: "Continue" }).click()
14+
await expect(dialog).toHaveCount(0)
15+
await expect(component.getByRole("button", { name: "Authenticate", exact: true })).toHaveCount(0)
16+
})
17+
18+
story("cancelling a version mismatch permits reconnecting again", async ({ mount, page }) => {
19+
const component = await mount("app-dialog-ssh--incompatible-session")
20+
await component.getByRole("button", { name: "Reconnect", exact: true }).click()
21+
const dialog = page.getByRole("dialog")
22+
await expect(dialog.getByRole("alert")).toBeVisible()
23+
await dialog.getByRole("button", { name: "Cancel", exact: true }).click()
24+
await expect(dialog).toHaveCount(0)
25+
await component.getByRole("button", { name: "Reconnect", exact: true }).click()
26+
await expect(dialog.getByRole("alert")).toBeVisible()
27+
await dialog.getByRole("button", { name: "Cancel", exact: true }).click()
28+
await expect(dialog).toHaveCount(0)
29+
})
30+
31+
story("adding a server keeps all SSH challenges in the original connection dialog", async ({ mount, page }) => {
32+
await mount("app-dialog-ssh--host")
33+
const dialog = page.getByRole("dialog")
34+
await dialog.getByRole("textbox", { name: "Host or SSH command" }).fill("ssh devbox")
35+
await dialog.getByRole("button", { name: "Add server", exact: true }).click()
36+
await expect(dialog).toHaveCount(1)
37+
await expect(dialog.getByRole("button", { name: "Cancel", exact: true })).toBeFocused()
38+
await dialog.getByRole("button", { name: "Trust and connect" }).click()
39+
await dialog.getByRole("textbox").fill("fixture-password")
40+
await dialog.getByRole("button", { name: "Continue", exact: true }).click()
41+
await dialog.getByRole("textbox", { name: "Verification code:" }).fill("123456")
42+
await dialog.getByRole("button", { name: "Continue", exact: true }).click()
43+
await expect(dialog).toHaveCount(0)
44+
})
45+
46+
story("updating an incompatible connection continues authentication in the same dialog", async ({ mount, page }) => {
47+
const component = await mount("app-dialog-ssh--incompatible-session")
48+
await component.getByRole("button", { name: "Reconnect", exact: true }).click()
49+
const dialog = page.getByRole("dialog")
50+
await dialog.getByRole("button", { name: "Update and reconnect", exact: true }).click()
51+
await expect(dialog).toHaveCount(1)
52+
await dialog.getByRole("textbox").fill("fixture-password")
53+
await dialog.getByRole("button", { name: "Continue", exact: true }).click()
54+
await dialog.getByRole("textbox", { name: "Verification code:" }).fill("123456")
55+
await dialog.getByRole("button", { name: "Continue", exact: true }).click()
56+
await expect(dialog).toHaveCount(0)
57+
await expect(component.getByText("Session connected")).toBeVisible()
58+
})
59+
60+
story("key-based reconnect completes without opening an authentication dialog", async ({ mount, page }) => {
61+
const component = await mount("app-dialog-ssh--key-reconnect")
62+
await component.getByRole("button", { name: "Reconnect", exact: true }).click()
63+
await expect(component.getByRole("button", { name: "Connecting to SSH server" })).toBeDisabled()
64+
await expect(page.getByRole("dialog")).toHaveCount(0)
65+
await expect(component.getByText("Session connected")).toBeVisible()
66+
await expect(page.getByRole("dialog")).toHaveCount(0)
67+
})

‎packages/app/package.json‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
"./i18n/desktop-native": "./src/runtime/i18n/desktop-native.ts",
1111
"./updater": "./src/shell/updates/types.ts",
1212
"./wsl/types": "./src/servers/wsl/types.ts",
13+
"./ssh": "./src/servers/ssh/types.ts",
1314
"./vite": "./vite.js",
1415
"./index.css": "./src/index.css"
1516
},

‎packages/app/src/app.tsx‎

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,8 @@ import { ServerConnection, ServersProvider } from "@/runtime/server/registry"
1717
import { SettingsProvider } from "@/settings/model"
1818
import { TabsProvider } from "@/shell/tabs/tabs"
1919
import { WslServersProvider } from "@/servers/wsl/context"
20+
import { SshProvider } from "@/servers/ssh/context"
21+
import { SshRestore } from "@/servers/ssh/restore"
2022
import { ErrorPage } from "@/shell/errors/error"
2123
import { AppRoutes, File, preloadRoute } from "@/shell/routes/routes"
2224

@@ -81,7 +83,9 @@ export function AppBaseProviders(
8183
<QueryProvider>
8284
<WslServersProvider>
8385
<DialogProvider>
84-
<FileComponentProvider component={File}>{props.children}</FileComponentProvider>
86+
<SshProvider>
87+
<FileComponentProvider component={File}>{props.children}</FileComponentProvider>
88+
</SshProvider>
8589
</DialogProvider>
8690
</WslServersProvider>
8791
</QueryProvider>
@@ -109,6 +113,7 @@ export function AppInterface(props: {
109113
<BodyTypography />
110114
<CommandProvider>
111115
<DesktopCommands />
116+
<SshRestore />
112117
<HighlightsProvider>
113118
{props.children}
114119
{rootProps.children}

‎packages/app/src/composer/editor/interaction.ts‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ export type ComposerEditorView = {
3737
agent?: ComposerSelectControl
3838
variant?: ComposerSelectControl
3939
submit: {
40+
available?: Accessor<boolean>
4041
stopping: Accessor<boolean>
4142
working?: Accessor<boolean>
4243
queue?: ComposerQueue
@@ -333,6 +334,7 @@ export function createComposerEditor(input: {
333334
draft.removeAttachment(id)
334335
},
335336
canSubmit() {
337+
if (input.view.submit.available?.() === false) return false
336338
if (input.view.draftOnly) return false
337339
const persisted = draft.state
338340
if (state.mode === "shell") {
@@ -365,6 +367,7 @@ export function createComposerEditor(input: {
365367
dispatch({ type: "mode.shell" })
366368
},
367369
submit(options?: { alternate?: boolean }) {
370+
if (input.view.submit.available?.() === false) return
368371
if (input.view.draftOnly) return
369372
input.view.submit.onSubmit(options)
370373
dispatch({ type: "popover.close" })

‎packages/app/src/composer/model.ts‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ import { useLanguage } from "@/runtime/i18n/language"
1111
import { useLayout } from "@/shell/state/layout"
1212
import { usePlatform } from "@/runtime/platform/platform"
1313
import { useWorkspaceLocation } from "@/workspaces/location"
14-
import { useData } from "@/runtime/server/current"
14+
import { useData, useServer } from "@/runtime/server/current"
1515
import { createSessionTabs } from "@/session/helpers"
1616
import { showToast } from "@/shell/notifications/toast"
1717
import { formatServerError } from "@/runtime/server/errors"
@@ -30,6 +30,8 @@ export type ComposerModel = ComposerEditorModel & {
3030
export function createComposerModel(adapter: ComposerAdapter, options?: { queue?: ComposerQueue }): ComposerModel {
3131
const sdk = useWorkspaceLocation()
3232
const data = useData()
33+
const server = useServer()
34+
const available = () => server.conn.type !== "ssh" || server.ctx.sdk.connection.status() === "connected"
3335
const files = useFile()
3436
const layout = useLayout()
3537
const comments = useComments()
@@ -394,10 +396,12 @@ export function createComposerModel(adapter: ComposerAdapter, options?: { queue?
394396
keybind: () => command.keybindParts("model.variant.cycle"),
395397
},
396398
submit: {
399+
available,
397400
stopping,
398401
working: adapter.working,
399402
queue: options?.queue,
400403
onSubmit: (submitOptions) => {
404+
if (!available()) return
401405
const queue = options?.queue
402406
// Confirming an edit re-admits the queued prompt instead of sending
403407
// the composer value as a new prompt. Enter keeps it queued in

‎packages/app/src/desktop.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,4 +20,5 @@ export { createDraftStore } from "./runtime/persistence/drafts"
2020
export { createNamespaceStorage, type NamespaceStorage } from "./runtime/persistence/namespace"
2121
export { flushPersisted } from "./runtime/persistence/persist"
2222
export { useWslServers } from "./servers/wsl/context"
23+
export { useSsh } from "./servers/ssh/context"
2324
export { type UpdaterPlatform, type UpdaterState } from "./shell/updates/types"

‎packages/app/src/home/projects/controller.tsx‎

Lines changed: 26 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import { Persistence } from "@/runtime/persistence/schema"
1515
import type { HomeController } from "../model"
1616
import { useGlobal } from "@/runtime/server/runtime"
1717
import { SessionTransfer } from "@opencode/schema/session-transfer"
18+
import { useSshAuthenticate } from "@/servers/ssh/authenticate"
1819

1920
export const HomeServersSchema = Schema.Struct({
2021
collapsed: Persistence.record(Persistence.fallback(Schema.Boolean, () => false)),
@@ -28,6 +29,7 @@ export function createHomeProjectsController(home: HomeController) {
2829
const openSettings = useSettingsCommand()
2930
const serverManagement = useServerActionsController()
3031
const global = useGlobal()
32+
const authenticate = useSshAuthenticate()
3133
const [_state, setState, _, ready] = persisted(Persist.global("home.servers"), HomeServersSchema, { collapsed: {} })
3234
const [state] = createResource(
3335
() => ready.promise ?? Promise.resolve(),
@@ -42,6 +44,15 @@ export function createHomeProjectsController(home: HomeController) {
4244
return platform.platform === "desktop" && !!platform.openPath && ServerConnection.local(conn)
4345
}
4446

47+
function choose(conn: ServerConnection.Any) {
48+
pickDirectory({
49+
server: conn,
50+
title: language.t("command.project.open"),
51+
multiple: true,
52+
onSelect: (result) => home.project.add(conn, homeProjectDirectories(result)),
53+
})
54+
}
55+
4556
return {
4657
copy: {
4758
language,
@@ -71,15 +82,25 @@ export function createHomeProjectsController(home: HomeController) {
7182
void dialog.show(() => <DialogServer mode="edit" server={conn} />)
7283
})
7384
},
74-
focus: home.selection.focusServer,
85+
authenticate: (conn: ServerConnection.Any) => authenticate(conn),
86+
focus: (conn: ServerConnection.Any) => {
87+
if (authenticate(conn, () => home.selection.focusServer(conn))) return
88+
home.selection.focusServer(conn)
89+
},
7590
},
7691
project: {
7792
list: home.project.list,
7893
recentlyClosed: home.project.recentlyClosed,
7994
homedir: home.project.homedir,
80-
select: home.project.select,
95+
select: (conn: ServerConnection.Any, directory: string) => {
96+
if (authenticate(conn, () => home.project.select(conn, directory))) return
97+
home.project.select(conn, directory)
98+
},
8199
add: home.project.add,
82-
openNewSession: home.project.openProjectNewSession,
100+
openNewSession: (conn: ServerConnection.Any, directory: string) => {
101+
if (authenticate(conn, () => home.project.openProjectNewSession(conn, directory))) return
102+
home.project.openProjectNewSession(conn, directory)
103+
},
83104
canImportSession: !!platform.openAttachmentPickerDialog,
84105
importSession: (conn: ServerConnection.Any, project: LocalProject) => {
85106
if (!platform.openAttachmentPickerDialog) return
@@ -125,13 +146,9 @@ export function createHomeProjectsController(home: HomeController) {
125146
.forEach((directory) => notification.project.markViewed(directory))
126147
},
127148
choose: (conn: ServerConnection.Any) => {
149+
if (authenticate(conn, () => choose(conn))) return
128150
if (home.server.health(conn)?.healthy === false) return
129-
pickDirectory({
130-
server: conn,
131-
title: language.t("command.project.open"),
132-
multiple: true,
133-
onSelect: (result) => home.project.add(conn, homeProjectDirectories(result)),
134-
})
151+
choose(conn)
135152
},
136153
close: (conn: ServerConnection.Any, directory: string) => {
137154
const next = closeHomeProject(

‎packages/app/src/home/projects/region.tsx‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ export function HomeProjects(props: {
2626
onWheel={props.scroll.viewport.containWheel}
2727
onChooseProject={props.projects.project.choose}
2828
onFocusServer={props.projects.server.focus}
29+
onAuthenticateServer={props.projects.server.authenticate}
2930
onToggleCollapsed={props.projects.server.toggleCollapsed}
3031
onEditServer={props.projects.server.edit}
3132
onSetDefaultServer={props.projects.server.setDefault}
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
/* Home server/project rows keep the label underneath the hover actions.
2+
The actions carry a tab-style background with a fade on the left, and the
3+
label fades out where it slides underneath. Mirrors tab-nav.css. */
4+
[data-home-row] {
5+
--home-row-surface: var(--v2-background-bg-base);
6+
--home-row-background: color-mix(
7+
in srgb,
8+
var(--home-row-surface) var(--home-row-opacity, 100%),
9+
var(--v2-background-bg-base)
10+
);
11+
background: var(--home-row-background);
12+
}
13+
14+
[data-home-row]:is(:hover, :focus-within, [data-dragging="true"], :has([data-menu="true"])) {
15+
--home-row-surface: var(--v2-background-bg-layer-01);
16+
}
17+
18+
[data-home-row][data-selected] {
19+
--home-row-surface: var(--v2-background-bg-layer-03);
20+
}
21+
22+
[data-home-row][data-dimmed="true"] {
23+
--home-row-opacity: 60%;
24+
}
25+
26+
/* Keep the background outside the button's disabled-content opacity. */
27+
[data-home-row] > button {
28+
background: transparent;
29+
}
30+
31+
[data-home-row] [data-slot="home-row-actions"] {
32+
background: linear-gradient(to right, transparent, var(--home-row-background) 8px);
33+
}
34+
35+
[data-home-row]:dir(rtl) [data-slot="home-row-actions"] {
36+
background: linear-gradient(to left, transparent, var(--home-row-background) 8px);
37+
}
38+
39+
[data-home-row]:is(:hover, :focus-within, :has([data-menu="true"])) [data-slot="home-row-label"] {
40+
-webkit-mask-image: linear-gradient(to right, black 0, black calc(100% - 44px), transparent calc(100% - 36px));
41+
mask-image: linear-gradient(to right, black 0, black calc(100% - 44px), transparent calc(100% - 36px));
42+
}
43+
44+
[data-home-row]:is(:hover, :focus-within, :has([data-menu="true"])):dir(rtl) [data-slot="home-row-label"] {
45+
-webkit-mask-image: linear-gradient(to left, black 0, black calc(100% - 44px), transparent calc(100% - 36px));
46+
mask-image: linear-gradient(to left, black 0, black calc(100% - 44px), transparent calc(100% - 36px));
47+
}
48+
49+
@media (hover: none) {
50+
[data-home-row] [data-slot="home-row-label"] {
51+
-webkit-mask-image: linear-gradient(to right, black 0, black calc(100% - 44px), transparent calc(100% - 36px));
52+
mask-image: linear-gradient(to right, black 0, black calc(100% - 44px), transparent calc(100% - 36px));
53+
}
54+
55+
[data-home-row]:dir(rtl) [data-slot="home-row-label"] {
56+
-webkit-mask-image: linear-gradient(to left, black 0, black calc(100% - 44px), transparent calc(100% - 36px));
57+
mask-image: linear-gradient(to left, black 0, black calc(100% - 44px), transparent calc(100% - 36px));
58+
}
59+
}

0 commit comments

Comments
 (0)