From 9d4a0179d1a15c09784d8d15fa782eb79e47e2c7 Mon Sep 17 00:00:00 2001 From: Federico Jaramillo Martinez Date: Wed, 20 May 2026 23:30:11 +0200 Subject: [PATCH] feat: deep link terminal selection --- .changeset/deep-link-terminals.md | 5 ++ docs/plugins.md | 13 +++- src/client/src/appState.ts | 2 + src/client/src/components/PiWebApp.ts | 73 +++++++++++++++---- src/client/src/components/TerminalPanel.ts | 64 +++++++++++++--- src/client/src/components/WorkspacePanel.ts | 6 ++ .../src/controllers/terminalSelection.test.ts | 37 ++++++++++ .../src/controllers/terminalSelection.ts | 46 ++++++++++++ .../src/controllers/workspaceController.ts | 6 +- src/client/src/plugins/core/panels.ts | 2 +- src/client/src/plugins/registry.test.ts | 1 + src/client/src/plugins/types.ts | 4 + 12 files changed, 229 insertions(+), 30 deletions(-) create mode 100644 .changeset/deep-link-terminals.md create mode 100644 src/client/src/controllers/terminalSelection.test.ts create mode 100644 src/client/src/controllers/terminalSelection.ts diff --git a/.changeset/deep-link-terminals.md b/.changeset/deep-link-terminals.md new file mode 100644 index 0000000..2f22b75 --- /dev/null +++ b/.changeset/deep-link-terminals.md @@ -0,0 +1,5 @@ +--- +"@jmfederico/pi-web": patch +--- + +Deep-link terminal selection so action-created terminals open directly and reload back to the same terminal. diff --git a/docs/plugins.md b/docs/plugins.md index ad04872..6855161 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -311,6 +311,7 @@ interface PluginRuntimeContext { configureAuth: () => void | Promise; logoutAuth: () => void | Promise; selectWorkspaceTool: (tool: QualifiedContributionId) => void; + openTerminal?: (options?: { terminalId?: string }) => void; refreshFiles: () => void | Promise; refreshGit: () => void | Promise; startSession: () => void | Promise; @@ -326,6 +327,7 @@ Notes: - Other `state` fields may exist at runtime, but they are Pi Web internals and can change quickly. - `enabled` is evaluated when the action palette asks for actions. - `selectWorkspaceTool()` expects a qualified panel id such as `my-plugin:workspace.info`. +- `openTerminal()` switches to the built-in terminal panel. Pass `{ terminalId }` to deep-link to a specific terminal after creating one through the terminal API. #### Keyboard shortcuts @@ -366,12 +368,17 @@ interface WorkspacePanelContribution { title: string; order?: number; visible?: (context: { workspace: Workspace }) => boolean; - badge?: (context: { workspace: Workspace }) => string | number | TemplateResult | undefined; - render: (context: { workspace: Workspace }) => TemplateResult; + badge?: (context: WorkspacePanelContext) => string | number | TemplateResult | undefined; + render: (context: WorkspacePanelContext) => TemplateResult; +} + +interface WorkspacePanelContext { + workspace: Workspace; + openTerminal?: (options?: { terminalId?: string }) => void; } ``` -Only `workspace` is documented as stable for panel callbacks. Other fields may exist at runtime, but they are Pi Web internals and can change quickly. If a panel needs file, git, or session data, prefer explicit `fetch()` calls and keep them isolated. +`workspace` and `openTerminal()` are documented as stable for panel callbacks. Other fields may exist at runtime, but they are Pi Web internals and can change quickly. Use `openTerminal?.({ terminalId })` when a panel creates a terminal and wants Pi Web to navigate to that specific terminal. If a panel needs file, git, or session data, prefer explicit `fetch()` calls and keep them isolated. Useful workspace shape: diff --git a/src/client/src/appState.ts b/src/client/src/appState.ts index dd3a37f..81fc64f 100644 --- a/src/client/src/appState.ts +++ b/src/client/src/appState.ts @@ -40,6 +40,7 @@ export interface AppState { selectedStagedDiff: GitDiffResponse | undefined; gitStale: boolean; activeTerminalCount: number; + selectedTerminalId: string | undefined; piWebStatus: PiWebStatusResponse | undefined; error: string; } @@ -90,6 +91,7 @@ export function initialAppState(): AppState { selectedStagedDiff: undefined, gitStale: false, activeTerminalCount: 0, + selectedTerminalId: undefined, piWebStatus: undefined, error: "", }; diff --git a/src/client/src/components/PiWebApp.ts b/src/client/src/components/PiWebApp.ts index 779e6f2..3382176 100644 --- a/src/client/src/components/PiWebApp.ts +++ b/src/client/src/components/PiWebApp.ts @@ -11,6 +11,7 @@ import { GitController } from "../controllers/gitController"; import { ProjectController } from "../controllers/projectController"; import { SessionController } from "../controllers/sessionController"; import { WorkspaceController } from "../controllers/workspaceController"; +import { InMemoryTerminalSelectionMemory } from "../controllers/terminalSelection"; import { KeyboardShortcutDispatcher } from "../keyboardShortcuts"; import { RealtimeSocket } from "../sessionSocket"; import type { QualifiedContributionId, QualifiedThemeContribution, QualifiedThemePairContribution, QualifiedWorkspacePanelContribution, PluginRuntimeContext, WorkspacePanelContext } from "../plugins/types"; @@ -19,7 +20,7 @@ import { corePlugin } from "../plugins/core"; import { themePackPlugin } from "../plugins/themes"; import { loadExternalPlugins } from "../plugins/external"; import { PluginRegistry } from "../plugins/registry"; -import { queryNamespace, readNamespacedString } from "../namespacedQueryArgs"; +import { queryNamespace, readNamespacedString, setNamespacedQueryKey } from "../namespacedQueryArgs"; import { readRoute, writeRoute, type AppRoute } from "../route"; import "./ProjectList"; import "./WorkspaceList"; @@ -42,6 +43,7 @@ const PI_WEB_STATUS_REFRESH_MS = 15 * 60 * 1000; const THEME_AUTO_ON_VALUE = "auto:on"; const THEME_AUTO_OFF_VALUE = "auto:off"; const THEME_OPTION_PREFIX = "theme:"; +const TERMINAL_ROUTE_NAMESPACE = queryNamespace("core:workspace.terminal"); @customElement("pi-web-app") export class PiWebApp extends LitElement { @@ -89,6 +91,7 @@ export class PiWebApp extends LitElement { private readonly keyboard = new KeyboardShortcutDispatcher(); private readonly realtime = new RealtimeSocket(); private readonly activeTerminalIds = new Set(); + private readonly terminalSelection = new InMemoryTerminalSelectionMemory(); private readonly mobileNavigationMedia = typeof window !== "undefined" && "matchMedia" in window ? window.matchMedia("(max-width: 760px)") : undefined; private readonly systemLightThemeMedia = typeof window !== "undefined" && "matchMedia" in window ? window.matchMedia("(prefers-color-scheme: light)") : undefined; private observedContextItems: HTMLElement | undefined; @@ -97,6 +100,8 @@ export class PiWebApp extends LitElement { private mobileTabsResizeObserver: ResizeObserver | undefined; private terminalAutoStartWorkspaceId: string | undefined; private piWebStatusTimer: number | undefined; + private routeRestoreInProgress = false; + private restoringRouteTerminalId: string | undefined; private readonly plugins = createPluginRegistry(); private themePreference: ThemePreference = readStoredThemePreference() ?? DEFAULT_THEME_PREFERENCE; @state() private activeThemeId: QualifiedContributionId = CLASSIC_THEME_ID; @@ -227,19 +232,29 @@ export class PiWebApp extends LitElement { const route = readRoute(); const selectedFilePath = readNamespacedString(queryNamespace("core:workspace.files"), "file"); const selectedDiffPath = readNamespacedString(queryNamespace("core:workspace.git"), "diff"); - this.setState({ workspaceTool: route.tool ?? this.state.workspaceTool, mainView: route.view ?? this.defaultRouteView(), selectedFilePath, selectedDiffPath }); - if (route.projectId === undefined || route.projectId === "") return; - if (this.routeMatchesCurrentSelection(route)) { + const selectedTerminalId = readNamespacedString(TERMINAL_ROUTE_NAMESPACE, "terminal"); + this.routeRestoreInProgress = true; + this.restoringRouteTerminalId = selectedTerminalId; + try { + this.setState({ workspaceTool: route.tool ?? this.state.workspaceTool, mainView: route.view ?? this.defaultRouteView(), selectedFilePath, selectedDiffPath, selectedTerminalId }); + if (route.projectId === undefined || route.projectId === "") return; + if (this.routeMatchesCurrentSelection(route)) { + if (selectedTerminalId !== undefined) this.rememberSelectedTerminal(selectedTerminalId); + await this.refreshRestoredWorkspaceTool(route.tool, selectedFilePath); + this.git.updatePolling(); + return; + } + const project = this.state.projects.find((p) => p.id === route.projectId); + if (!project) return; + await this.workspaces.selectProject(project, { workspaceId: route.workspaceId, sessionId: route.sessionId, updateUrl }); + this.setState({ selectedFilePath, selectedDiffPath, selectedTerminalId }); + if (selectedTerminalId !== undefined) this.rememberSelectedTerminal(selectedTerminalId); await this.refreshRestoredWorkspaceTool(route.tool, selectedFilePath); this.git.updatePolling(); - return; + } finally { + this.routeRestoreInProgress = false; + this.restoringRouteTerminalId = undefined; } - const project = this.state.projects.find((p) => p.id === route.projectId); - if (!project) return; - await this.workspaces.selectProject(project, { workspaceId: route.workspaceId, sessionId: route.sessionId, updateUrl }); - this.setState({ selectedFilePath, selectedDiffPath }); - await this.refreshRestoredWorkspaceTool(route.tool, selectedFilePath); - this.git.updatePolling(); } private routeMatchesCurrentSelection(route: AppRoute): boolean { @@ -294,6 +309,28 @@ export class PiWebApp extends LitElement { this.git.updatePolling(); } + private openTerminal(options?: { terminalId?: string | undefined }): void { + if (options?.terminalId !== undefined) this.selectTerminal(options.terminalId, { replace: true }); + this.openWorkspaceTool("core:workspace.terminal"); + } + + private selectTerminal(terminalId: string | undefined, options?: { replace?: boolean | undefined }): void { + this.rememberSelectedTerminal(terminalId); + this.setState({ selectedTerminalId: terminalId }); + this.writeSelectedTerminalToUrl(terminalId, options); + } + + private rememberSelectedTerminal(terminalId: string | undefined): void { + const workspace = this.state.selectedWorkspace; + if (workspace === undefined) return; + if (terminalId === undefined) this.terminalSelection.forgetWorkspace(workspace.path); + else this.terminalSelection.rememberTerminal(workspace.path, terminalId); + } + + private writeSelectedTerminalToUrl(terminalId: string | undefined, options?: { replace?: boolean | undefined }): void { + setNamespacedQueryKey(TERMINAL_ROUTE_NAMESPACE, "terminal", terminalId, options); + } + private selectMainView(view: AppState["mainView"]) { if (view !== "navigation" && view !== "chat") { this.openWorkspaceTool(view); @@ -308,7 +345,9 @@ export class PiWebApp extends LitElement { if (previous.selectedWorkspace?.id === next.selectedWorkspace?.id) return; this.terminalAutoStartWorkspaceId = undefined; this.activeTerminalIds.clear(); - this.setState({ activeTerminalCount: 0 }); + const selectedTerminalId = this.routeRestoreInProgress ? this.restoringRouteTerminalId : next.selectedWorkspace === undefined ? undefined : this.terminalSelection.latestTerminalId(next.selectedWorkspace.path); + this.setState({ activeTerminalCount: 0, selectedTerminalId }); + if (!this.routeRestoreInProgress) this.writeSelectedTerminalToUrl(selectedTerminalId, { replace: true }); if (next.selectedWorkspace === undefined) return; void this.refreshActiveTerminals(next.selectedWorkspace); this.refreshSelectedWorkspaceTool(next.workspaceTool); @@ -339,6 +378,10 @@ export class PiWebApp extends LitElement { if (cwd !== workspace.path) return; if (event.type === "terminal.created" && !event.terminal.exited) this.activeTerminalIds.add(event.terminal.id); else this.activeTerminalIds.delete(event.type === "terminal.closed" ? event.terminalId : event.terminal.id); + if (event.type === "terminal.closed") { + this.terminalSelection.forgetTerminal(event.terminalId); + if (this.state.selectedTerminalId === event.terminalId) this.selectTerminal(undefined, { replace: true }); + } this.setState({ activeTerminalCount: this.activeTerminalIds.size }); } @@ -372,7 +415,7 @@ export class PiWebApp extends LitElement { private renderWorkspacePanel() { const workspaceLabelItems = this.state.selectedWorkspace === undefined ? [] : this.plugins.getWorkspaceLabelItems(this.state, this.state.selectedWorkspace); - return html` { this.openWorkspaceTool(tool); }} .onRefreshFiles=${() => this.files.refreshFiles()} .onExpandDir=${(path: string) => this.files.expandDir(path)} .onSelectFile=${(path: string) => this.files.selectFile(path)} .onRefreshGit=${() => this.git.refreshGit()} .onSelectDiff=${(path: string) => this.git.selectDiff(path)}>`; + return html` { this.openTerminal(options); }} .onSelectTool=${(tool: QualifiedContributionId) => { this.openWorkspaceTool(tool); }} .onRefreshFiles=${() => this.files.refreshFiles()} .onExpandDir=${(path: string) => this.files.expandDir(path)} .onSelectFile=${(path: string) => this.files.selectFile(path)} .onRefreshGit=${() => this.git.refreshGit()} .onSelectDiff=${(path: string) => this.git.selectDiff(path)} .onSelectTerminal=${(terminalId: string | undefined, options?: { replace?: boolean | undefined }) => { this.selectTerminal(terminalId, options); }}>`; } private renderNavigationPanel(autoSwitchToChat: boolean) { @@ -486,12 +529,15 @@ export class PiWebApp extends LitElement { selectedStagedDiff: this.state.selectedStagedDiff, gitStale: this.state.gitStale, activeTerminalCount: this.state.activeTerminalCount, + selectedTerminalId: this.state.selectedTerminalId, terminalAutoStart: this.terminalAutoStartWorkspaceId === workspace.id, + openTerminal: (options) => { this.openTerminal(options); }, onRefreshFiles: () => { void this.files.refreshFiles(); }, onExpandDir: (path: string) => { void this.files.expandDir(path); }, onSelectFile: (path: string) => { void this.files.selectFile(path); }, onRefreshGit: () => { void this.git.refreshGit(); }, onSelectDiff: (path: string) => { void this.git.selectDiff(path); }, + onSelectTerminal: (terminalId: string | undefined, options?: { replace?: boolean | undefined }) => { this.selectTerminal(terminalId, options); }, }; } @@ -527,6 +573,7 @@ export class PiWebApp extends LitElement { openThemePicker: () => { this.openThemeDialog(); }, selectMainView: (view) => { this.selectMainView(view); }, selectWorkspaceTool: (tool) => { this.openWorkspaceTool(tool); }, + openTerminal: (options) => { this.openTerminal(options); }, refreshFiles: () => this.files.refreshFiles(), refreshGit: () => this.git.refreshGit(), startSession: () => this.withChatScrollTransition(() => this.sessions.startSession()), diff --git a/src/client/src/components/TerminalPanel.ts b/src/client/src/components/TerminalPanel.ts index 1b0f851..2168c19 100644 --- a/src/client/src/components/TerminalPanel.ts +++ b/src/client/src/components/TerminalPanel.ts @@ -1,9 +1,10 @@ -import { css, html, LitElement } from "lit"; +import { css, html, LitElement, type PropertyValues } from "lit"; import { customElement, property, query, state } from "lit/decorators.js"; import { Terminal, type ITerminalOptions, type ITheme } from "@xterm/xterm"; import { FitAddon, type ITerminalDimensions } from "@xterm/addon-fit"; import "@xterm/xterm/css/xterm.css"; import { terminalSocket, terminalsApi, type TerminalInfo, type Workspace } from "../api"; +import { selectFallbackTerminal, selectPreferredTerminal } from "../controllers/terminalSelection"; const TERMINAL_OPTIONS_BASE: ITerminalOptions = { cursorBlink: true, @@ -17,7 +18,9 @@ const DEFAULT_TERMINAL_SIZE: TerminalSize = { cols: 100, rows: 30 }; @customElement("terminal-panel") export class TerminalPanel extends LitElement { @property({ attribute: false }) workspace: Workspace | undefined; + @property({ attribute: false }) selectedTerminalId: string | undefined; @property({ type: Boolean }) autoStart = false; + @property({ attribute: false }) onSelectTerminal: (terminalId: string | undefined, options?: { replace?: boolean | undefined }) => void = () => undefined; @query(".terminal-host") private terminalHost?: HTMLDivElement | null; @state() private terminals: TerminalInfo[] = []; @state() private selectedId: string | undefined; @@ -57,7 +60,7 @@ export class TerminalPanel extends LitElement { super.disconnectedCallback(); } - override willUpdate(): void { + override willUpdate(changed: PropertyValues): void { const cwd = this.workspace?.path; if (cwd !== this.observedCwd) { this.observedCwd = cwd; @@ -65,11 +68,22 @@ export class TerminalPanel extends LitElement { this.terminals = []; this.selectedId = undefined; this.disposeTerminalView(); + return; + } + if (changed.has("selectedTerminalId")) { + const previousTerminalId = changed.get("selectedTerminalId"); + if (previousTerminalId !== undefined && this.selectedTerminalId === undefined) { + this.loadedCwd = undefined; + this.selectTerminalIdInView(undefined); + return; + } + this.applyRequestedTerminalSelection(); } } - override updated(): void { + override updated(changed: PropertyValues): void { this.loadVisibleWorkspaceTerminals(); + if (changed.has("selectedTerminalId") && this.shouldReloadForRequestedTerminal()) void this.loadTerminals(); this.ensureTerminalView(); } @@ -87,7 +101,7 @@ export class TerminalPanel extends LitElement { if (this.workspace === undefined) return; const terminals = await terminalsApi.terminals(this.workspace.projectId, this.workspace.id); this.terminals = terminals; - this.selectedId = terminals.find((terminal) => !terminal.exited)?.id ?? terminals[0]?.id; + this.selectPreferredLoadedTerminal({ replaceUrl: true }); if (terminals.length === 0 && this.autoStart) await this.startTerminal(); } catch (error) { this.error = error instanceof Error ? error.message : String(error); @@ -96,6 +110,36 @@ export class TerminalPanel extends LitElement { } } + private applyRequestedTerminalSelection(): void { + if (this.selectedTerminalId !== undefined && !this.terminals.some((terminal) => terminal.id === this.selectedTerminalId)) return; + this.selectPreferredLoadedTerminal({ replaceUrl: true }); + } + + private shouldReloadForRequestedTerminal(): boolean { + const cwd = this.workspace?.path; + return this.visible + && cwd !== undefined + && cwd === this.loadedCwd + && this.selectedTerminalId !== undefined + && !this.loading + && !this.terminals.some((terminal) => terminal.id === this.selectedTerminalId); + } + + private selectPreferredLoadedTerminal(options?: { replaceUrl?: boolean | undefined }): void { + let terminal = selectPreferredTerminal(this.terminals, { targetTerminalId: this.selectedTerminalId }); + if (terminal === undefined && this.selectedTerminalId !== undefined) terminal = selectFallbackTerminal(this.terminals); + this.selectTerminalIdInView(terminal?.id); + if (terminal?.id !== this.selectedTerminalId || (terminal === undefined && this.selectedTerminalId !== undefined)) { + this.onSelectTerminal(terminal?.id, { replace: options?.replaceUrl === true }); + } + } + + private selectTerminalIdInView(id: string | undefined): void { + if (this.selectedId === id) return; + this.selectedId = id; + this.disposeTerminalView(); + } + private async startTerminal(): Promise { if (this.workspace === undefined) return; this.error = undefined; @@ -116,9 +160,10 @@ export class TerminalPanel extends LitElement { await terminalsApi.closeTerminal(this.workspace.projectId, this.workspace.id, id); const next = this.terminals.filter((terminal) => terminal.id !== id); this.terminals = next; - if (this.selectedId === id) { - this.selectedId = next[0]?.id; - this.disposeTerminalView(); + if (this.selectedId === id || this.selectedTerminalId === id) { + const nextSelectedId = selectFallbackTerminal(next)?.id; + this.selectTerminalIdInView(nextSelectedId); + this.onSelectTerminal(nextSelectedId, { replace: true }); } } catch (error) { this.error = error instanceof Error ? error.message : String(error); @@ -126,9 +171,8 @@ export class TerminalPanel extends LitElement { } private selectTerminal(id: string): void { - if (this.selectedId === id) return; - this.selectedId = id; - this.disposeTerminalView(); + if (this.selectedId !== id) this.selectTerminalIdInView(id); + this.onSelectTerminal(id); } private ensureTerminalView(): void { diff --git a/src/client/src/components/WorkspacePanel.ts b/src/client/src/components/WorkspacePanel.ts index 57e2cc9..02c7449 100644 --- a/src/client/src/components/WorkspacePanel.ts +++ b/src/client/src/components/WorkspacePanel.ts @@ -31,7 +31,10 @@ export class WorkspacePanel extends LitElement { @property({ attribute: false }) onRefreshGit: () => void = () => undefined; @property({ attribute: false }) onSelectDiff: (path: string) => void = () => undefined; @property({ type: Number }) activeTerminalCount = 0; + @property({ attribute: false }) selectedTerminalId: string | undefined; @property({ type: Boolean }) terminalAutoStart = false; + @property({ attribute: false }) openTerminal: (options?: { terminalId?: string | undefined }) => void = () => undefined; + @property({ attribute: false }) onSelectTerminal: (terminalId: string | undefined, options?: { replace?: boolean | undefined }) => void = () => undefined; @query(".workspace-header-strip") private workspaceHeaderStrip?: HTMLElement | null; @state() private workspaceHeaderCanScrollLeft = false; @state() private workspaceHeaderCanScrollRight = false; @@ -140,12 +143,15 @@ export class WorkspacePanel extends LitElement { selectedStagedDiff: this.selectedStagedDiff, gitStale: this.gitStale, activeTerminalCount: this.activeTerminalCount, + selectedTerminalId: this.selectedTerminalId, terminalAutoStart: this.terminalAutoStart, + openTerminal: this.openTerminal, onRefreshFiles: this.onRefreshFiles, onExpandDir: this.onExpandDir, onSelectFile: this.onSelectFile, onRefreshGit: this.onRefreshGit, onSelectDiff: this.onSelectDiff, + onSelectTerminal: this.onSelectTerminal, }; } diff --git a/src/client/src/controllers/terminalSelection.test.ts b/src/client/src/controllers/terminalSelection.test.ts new file mode 100644 index 0000000..1aa860a --- /dev/null +++ b/src/client/src/controllers/terminalSelection.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, it } from "vitest"; +import type { TerminalInfo } from "../api"; +import { InMemoryTerminalSelectionMemory, selectFallbackTerminal, selectPreferredTerminal } from "./terminalSelection"; + +function terminal(id: string, exited = false): TerminalInfo { + return { id, cwd: "/repo", name: id, createdAt: "now", exited }; +} + +describe("terminal selection", () => { + it("prefers explicit route targets before remembered or default terminals", () => { + const terminals = [terminal("first"), terminal("target")]; + + expect(selectPreferredTerminal(terminals, { targetTerminalId: "target", latestTerminalId: "first" })?.id).toBe("target"); + }); + + it("uses remembered terminals when there is no route target", () => { + const terminals = [terminal("first"), terminal("remembered")]; + + expect(selectPreferredTerminal(terminals, { latestTerminalId: "remembered" })?.id).toBe("remembered"); + }); + + it("falls back to an active terminal and then any terminal", () => { + expect(selectPreferredTerminal([terminal("exited", true), terminal("active")])?.id).toBe("active"); + expect(selectFallbackTerminal([terminal("exited", true)])?.id).toBe("exited"); + }); + + it("remembers terminal ids per workspace cwd", () => { + const memory = new InMemoryTerminalSelectionMemory(); + memory.rememberTerminal("/repo", "t1"); + memory.rememberTerminal("/other", "t2"); + + expect(memory.latestTerminalId("/repo")).toBe("t1"); + memory.forgetTerminal("t1"); + expect(memory.latestTerminalId("/repo")).toBeUndefined(); + expect(memory.latestTerminalId("/other")).toBe("t2"); + }); +}); diff --git a/src/client/src/controllers/terminalSelection.ts b/src/client/src/controllers/terminalSelection.ts new file mode 100644 index 0000000..3aa06cc --- /dev/null +++ b/src/client/src/controllers/terminalSelection.ts @@ -0,0 +1,46 @@ +import type { TerminalInfo } from "../api"; + +export interface TerminalSelectionMemory { + latestTerminalId(cwd: string): string | undefined; + rememberTerminal(cwd: string, terminalId: string): void; + forgetWorkspace(cwd: string): void; + forgetTerminal(terminalId: string): void; +} + +export class InMemoryTerminalSelectionMemory implements TerminalSelectionMemory { + private readonly terminalIdsByCwd = new Map(); + + latestTerminalId(cwd: string): string | undefined { + return this.terminalIdsByCwd.get(cwd); + } + + rememberTerminal(cwd: string, terminalId: string): void { + this.terminalIdsByCwd.set(cwd, terminalId); + } + + forgetWorkspace(cwd: string): void { + this.terminalIdsByCwd.delete(cwd); + } + + forgetTerminal(terminalId: string): void { + for (const [cwd, rememberedTerminalId] of this.terminalIdsByCwd.entries()) { + if (rememberedTerminalId === terminalId) this.terminalIdsByCwd.delete(cwd); + } + } +} + +export function selectPreferredTerminal(terminals: TerminalInfo[], options?: { targetTerminalId?: string | undefined; latestTerminalId?: string | undefined }): TerminalInfo | undefined { + const targetTerminalId = options?.targetTerminalId; + if (targetTerminalId !== undefined && targetTerminalId !== "") return terminals.find((terminal) => terminal.id === targetTerminalId); + + const latestTerminalId = options?.latestTerminalId; + if (latestTerminalId !== undefined && latestTerminalId !== "") { + return terminals.find((terminal) => terminal.id === latestTerminalId) ?? terminals.find((terminal) => !terminal.exited) ?? terminals[0]; + } + + return terminals.find((terminal) => !terminal.exited) ?? terminals[0]; +} + +export function selectFallbackTerminal(terminals: TerminalInfo[]): TerminalInfo | undefined { + return terminals.find((terminal) => !terminal.exited) ?? terminals[0]; +} diff --git a/src/client/src/controllers/workspaceController.ts b/src/client/src/controllers/workspaceController.ts index f1a6ec4..f65580b 100644 --- a/src/client/src/controllers/workspaceController.ts +++ b/src/client/src/controllers/workspaceController.ts @@ -15,7 +15,7 @@ export class WorkspaceController { clearSelection(options?: { updateUrl?: boolean | undefined }) { this.sessions.clearActiveSession(); - this.setState({ selectedProject: undefined, selectedWorkspace: undefined, sessions: [], workspaces: [], fileTree: [], expandedDirs: {}, selectedFilePath: undefined, selectedFileContent: undefined, fileTreeStale: false, gitStatus: undefined, selectedDiffPath: undefined, selectedDiff: undefined, selectedStagedDiff: undefined, gitStale: false, error: "" }); + this.setState({ selectedProject: undefined, selectedWorkspace: undefined, sessions: [], workspaces: [], fileTree: [], expandedDirs: {}, selectedFilePath: undefined, selectedFileContent: undefined, fileTreeStale: false, gitStatus: undefined, selectedDiffPath: undefined, selectedDiff: undefined, selectedStagedDiff: undefined, gitStale: false, selectedTerminalId: undefined, error: "" }); if (options?.updateUrl !== false) this.updateUrl(); } @@ -27,7 +27,7 @@ export class WorkspaceController { async selectProject(project: Project, target?: RouteTarget) { this.sessions.clearActiveSession(); - this.setState({ selectedProject: project, selectedWorkspace: undefined, sessions: [], workspaces: [], fileTree: [], expandedDirs: {}, selectedFilePath: undefined, selectedFileContent: undefined, fileTreeStale: false, gitStatus: undefined, selectedDiffPath: undefined, selectedDiff: undefined, selectedStagedDiff: undefined, gitStale: false, error: "" }); + this.setState({ selectedProject: project, selectedWorkspace: undefined, sessions: [], workspaces: [], fileTree: [], expandedDirs: {}, selectedFilePath: undefined, selectedFileContent: undefined, fileTreeStale: false, gitStatus: undefined, selectedDiffPath: undefined, selectedDiff: undefined, selectedStagedDiff: undefined, gitStale: false, selectedTerminalId: undefined, error: "" }); try { const workspaces = await api.workspaces(project.id); this.setState({ workspaces, workspacesByProjectId: { ...this.getState().workspacesByProjectId, [project.id]: workspaces } }); @@ -42,7 +42,7 @@ export class WorkspaceController { async selectWorkspace(workspace: Workspace, target?: { sessionId?: string | undefined; updateUrl?: boolean | undefined }) { this.workspaceSelection.rememberWorkspace(workspace); this.sessions.clearActiveSession(); - this.setState({ selectedWorkspace: workspace, sessions: [], fileTree: [], expandedDirs: {}, selectedFilePath: undefined, selectedFileContent: undefined, fileTreeStale: false, gitStatus: undefined, selectedDiffPath: undefined, selectedDiff: undefined, selectedStagedDiff: undefined, gitStale: false, error: "" }); + this.setState({ selectedWorkspace: workspace, sessions: [], fileTree: [], expandedDirs: {}, selectedFilePath: undefined, selectedFileContent: undefined, fileTreeStale: false, gitStatus: undefined, selectedDiffPath: undefined, selectedDiff: undefined, selectedStagedDiff: undefined, gitStale: false, selectedTerminalId: undefined, error: "" }); try { const sessions = mergeCachedNewSessions(workspace.path, await api.sessions(workspace.path)); this.setState({ sessions }); diff --git a/src/client/src/plugins/core/panels.ts b/src/client/src/plugins/core/panels.ts index fe1940d..91dab38 100644 --- a/src/client/src/plugins/core/panels.ts +++ b/src/client/src/plugins/core/panels.ts @@ -76,7 +76,7 @@ function renderFileViewer(context: WorkspacePanelContext): TemplateResult { function renderTerminal(context: WorkspacePanelContext): TemplateResult { loadTerminalPanel(); - return html``; + return html``; } function renderGit(context: WorkspacePanelContext): TemplateResult { diff --git a/src/client/src/plugins/registry.test.ts b/src/client/src/plugins/registry.test.ts index f99b4da..73ea68e 100644 --- a/src/client/src/plugins/registry.test.ts +++ b/src/client/src/plugins/registry.test.ts @@ -18,6 +18,7 @@ function createContext(statePatch: Partial = {}) { openThemePicker: vi.fn(() => { calls.push("openThemePicker"); }), selectMainView: vi.fn((view: AppState["mainView"]) => { calls.push(`selectMainView:${view}`); }), selectWorkspaceTool: vi.fn((tool: AppState["workspaceTool"]) => { calls.push(`selectWorkspaceTool:${tool}`); }), + openTerminal: vi.fn((options?: { terminalId?: string | undefined }) => { calls.push(`openTerminal:${options?.terminalId ?? ""}`); }), refreshFiles: vi.fn(() => { calls.push("refreshFiles"); }), refreshGit: vi.fn(() => { calls.push("refreshGit"); }), startSession: vi.fn(() => { calls.push("startSession"); }), diff --git a/src/client/src/plugins/types.ts b/src/client/src/plugins/types.ts index 6ca95db..2b37580 100644 --- a/src/client/src/plugins/types.ts +++ b/src/client/src/plugins/types.ts @@ -47,6 +47,7 @@ export interface PluginRuntimeContext { openThemePicker: () => void; selectMainView: (view: AppState["mainView"]) => void; selectWorkspaceTool: (tool: QualifiedContributionId) => void; + openTerminal?: (options?: { terminalId?: string | undefined }) => void; refreshFiles: () => void | Promise; refreshGit: () => void | Promise; startSession: () => void | Promise; @@ -88,12 +89,15 @@ export interface WorkspacePanelContext { selectedStagedDiff: GitDiffResponse | undefined; gitStale: boolean; activeTerminalCount: number; + selectedTerminalId: string | undefined; terminalAutoStart: boolean; + openTerminal?: (options?: { terminalId?: string | undefined }) => void; onRefreshFiles: () => void; onExpandDir: (path: string) => void; onSelectFile: (path: string) => void; onRefreshGit: () => void; onSelectDiff: (path: string) => void; + onSelectTerminal: (terminalId: string | undefined, options?: { replace?: boolean | undefined }) => void; } export interface WorkspacePanelContribution {