Archived
feat: deep link terminal selection
This commit is contained in:
@@ -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
@@ -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:
|
||||
|
||||
|
||||
@@ -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: "",
|
||||
};
|
||||
|
||||
@@ -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()),
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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"); }),
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user