feat: deep link terminal selection

This commit is contained in:
Federico Jaramillo Martinez
2026-05-21 09:31:33 +02:00
parent 1a91094a5b
commit 9d4a0179d1
12 changed files with 229 additions and 30 deletions
+5
View File
@@ -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.
+10 -3
View File
@@ -311,6 +311,7 @@ interface PluginRuntimeContext {
configureAuth: () => void | Promise<void>;
logoutAuth: () => void | Promise<void>;
selectWorkspaceTool: (tool: QualifiedContributionId) => void;
openTerminal?: (options?: { terminalId?: string }) => void;
refreshFiles: () => void | Promise<void>;
refreshGit: () => void | Promise<void>;
startSession: () => void | Promise<void>;
@@ -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:
+2
View File
@@ -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: "",
};
+60 -13
View File
@@ -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<string>();
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`<workspace-panel .workspace=${this.state.selectedWorkspace} .appState=${this.state} .tool=${this.state.workspaceTool} .panels=${this.visibleWorkspacePanels()} .workspaceLabelItems=${workspaceLabelItems} .fileTree=${this.state.fileTree} .expandedDirs=${this.state.expandedDirs} .selectedFilePath=${this.state.selectedFilePath} .selectedFileContent=${this.state.selectedFileContent} .fileTreeStale=${this.state.fileTreeStale} .gitStatus=${this.state.gitStatus} .selectedDiffPath=${this.state.selectedDiffPath} .selectedDiff=${this.state.selectedDiff} .selectedStagedDiff=${this.state.selectedStagedDiff} .gitStale=${this.state.gitStale} .activeTerminalCount=${this.state.activeTerminalCount} .terminalAutoStart=${this.terminalAutoStartWorkspaceId === this.state.selectedWorkspace?.id} .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)}></workspace-panel>`;
return html`<workspace-panel .workspace=${this.state.selectedWorkspace} .appState=${this.state} .tool=${this.state.workspaceTool} .panels=${this.visibleWorkspacePanels()} .workspaceLabelItems=${workspaceLabelItems} .fileTree=${this.state.fileTree} .expandedDirs=${this.state.expandedDirs} .selectedFilePath=${this.state.selectedFilePath} .selectedFileContent=${this.state.selectedFileContent} .fileTreeStale=${this.state.fileTreeStale} .gitStatus=${this.state.gitStatus} .selectedDiffPath=${this.state.selectedDiffPath} .selectedDiff=${this.state.selectedDiff} .selectedStagedDiff=${this.state.selectedStagedDiff} .gitStale=${this.state.gitStale} .activeTerminalCount=${this.state.activeTerminalCount} .selectedTerminalId=${this.state.selectedTerminalId} .terminalAutoStart=${this.terminalAutoStartWorkspaceId === this.state.selectedWorkspace?.id} .openTerminal=${(options?: { terminalId?: string | undefined }) => { 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); }}></workspace-panel>`;
}
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()),
+54 -10
View File
@@ -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<this>): 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<this>): 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<void> {
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 {
@@ -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,
};
}
@@ -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");
});
});
@@ -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<string, string>();
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];
}
@@ -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 });
+1 -1
View File
@@ -76,7 +76,7 @@ function renderFileViewer(context: WorkspacePanelContext): TemplateResult {
function renderTerminal(context: WorkspacePanelContext): TemplateResult {
loadTerminalPanel();
return html`<terminal-panel .workspace=${context.workspace} .autoStart=${context.terminalAutoStart}></terminal-panel>`;
return html`<terminal-panel .workspace=${context.workspace} .selectedTerminalId=${context.selectedTerminalId} .autoStart=${context.terminalAutoStart} .onSelectTerminal=${context.onSelectTerminal}></terminal-panel>`;
}
function renderGit(context: WorkspacePanelContext): TemplateResult {
+1
View File
@@ -18,6 +18,7 @@ function createContext(statePatch: Partial<AppState> = {}) {
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"); }),
+4
View File
@@ -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<void>;
refreshGit: () => void | Promise<void>;
startSession: () => void | Promise<void>;
@@ -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 {