Namespace panel query state

This commit is contained in:
Federico Jaramillo Martinez
2026-05-09 22:28:47 +02:00
parent 9446220c45
commit 06fde71f92
7 changed files with 108 additions and 29 deletions
+8 -7
View File
@@ -13,6 +13,7 @@ import type { QualifiedContributionId, QualifiedWorkspacePanelContribution, Plug
import { corePlugin } from "../plugins/core"; import { corePlugin } from "../plugins/core";
import { examplePlugin } from "../plugins/example"; import { examplePlugin } from "../plugins/example";
import { PluginRegistry } from "../plugins/registry"; import { PluginRegistry } from "../plugins/registry";
import { queryNamespace, readNamespacedString } from "../namespacedQueryArgs";
import { readRoute, writeRoute } from "../route"; import { readRoute, writeRoute } from "../route";
import "./ProjectList"; import "./ProjectList";
import "./WorkspaceList"; import "./WorkspaceList";
@@ -101,15 +102,17 @@ export class PiWebApp extends LitElement {
private async restoreRoute(updateUrl: boolean) { private async restoreRoute(updateUrl: boolean) {
const route = readRoute(); const route = readRoute();
this.setState({ workspaceTool: route.tool ?? this.state.workspaceTool, mainView: route.view ?? this.state.mainView, selectedFilePath: route.file, selectedDiffPath: route.diff }); 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.state.mainView, selectedFilePath, selectedDiffPath });
if (route.projectId === undefined || route.projectId === "") return; if (route.projectId === undefined || route.projectId === "") return;
const project = this.state.projects.find((p) => p.id === route.projectId); const project = this.state.projects.find((p) => p.id === route.projectId);
if (!project) return; if (!project) return;
await this.workspaces.selectProject(project, { workspaceId: route.workspaceId, sessionId: route.sessionId, updateUrl }); await this.workspaces.selectProject(project, { workspaceId: route.workspaceId, sessionId: route.sessionId, updateUrl });
this.setState({ selectedFilePath, selectedDiffPath });
if (route.tool === "core:workspace.files") await this.files.refreshFiles(); if (route.tool === "core:workspace.files") await this.files.refreshFiles();
if (route.file !== undefined) await this.files.selectFile(route.file); if (route.tool === "core:workspace.files" && selectedFilePath !== undefined) await this.files.restoreFile(selectedFilePath);
if (route.tool === "core:workspace.git") await this.git.refreshGit(); if (route.tool === "core:workspace.git") await this.git.refreshGit();
if (route.diff !== undefined) await this.git.selectDiff(route.diff);
this.git.updatePolling(); this.git.updatePolling();
} }
@@ -132,16 +135,14 @@ export class PiWebApp extends LitElement {
this.chatView?.restorePrependScrollAnchor(anchor); this.chatView?.restorePrependScrollAnchor(anchor);
} }
private updateUrl() { private updateUrl(options?: { replace?: boolean | undefined }) {
writeRoute({ writeRoute({
projectId: this.state.selectedProject?.id, projectId: this.state.selectedProject?.id,
workspaceId: this.state.selectedWorkspace?.id, workspaceId: this.state.selectedWorkspace?.id,
sessionId: this.state.selectedSession?.id, sessionId: this.state.selectedSession?.id,
tool: this.state.workspaceTool, tool: this.state.workspaceTool,
view: this.state.mainView === "navigation" ? undefined : this.state.mainView, view: this.state.mainView === "navigation" ? undefined : this.state.mainView,
file: this.state.selectedFilePath, }, options);
diff: this.state.selectedDiffPath,
});
} }
private selectWorkspaceTool(tool: QualifiedContributionId) { private selectWorkspaceTool(tool: QualifiedContributionId) {
@@ -1,6 +1,9 @@
import { api } from "../api"; import { api } from "../api";
import { queryNamespace, setNamespacedQueryKey } from "../namespacedQueryArgs";
import type { GetState, SetState, UpdateUrl } from "./types"; import type { GetState, SetState, UpdateUrl } from "./types";
const FILES_ROUTE_NAMESPACE = queryNamespace("core:workspace.files");
export class FileExplorerController { export class FileExplorerController {
constructor(private readonly getState: GetState, private readonly setState: SetState, private readonly updateUrl: UpdateUrl) {} constructor(private readonly getState: GetState, private readonly setState: SetState, private readonly updateUrl: UpdateUrl) {}
@@ -35,11 +38,17 @@ export class FileExplorerController {
} }
async selectFile(path: string): Promise<void> { async selectFile(path: string): Promise<void> {
this.setState({ selectedFilePath: path, selectedFileContent: undefined, workspaceTool: "core:workspace.files", mainView: this.getState().mainView === "chat" ? "chat" : "core:workspace.files" });
setNamespacedQueryKey(FILES_ROUTE_NAMESPACE, "file", path);
this.updateUrl({ replace: true });
await this.restoreFile(path);
}
async restoreFile(path: string): Promise<void> {
const project = this.getState().selectedProject; const project = this.getState().selectedProject;
const workspace = this.getState().selectedWorkspace; const workspace = this.getState().selectedWorkspace;
if (project === undefined || workspace === undefined) return; if (project === undefined || workspace === undefined) return;
this.setState({ selectedFilePath: path, selectedFileContent: undefined, workspaceTool: "core:workspace.files", mainView: this.getState().mainView === "chat" ? "chat" : "core:workspace.files" }); this.setState({ selectedFilePath: path, selectedFileContent: undefined });
this.updateUrl();
try { try {
this.setState({ selectedFileContent: await api.workspaceFile(project.id, workspace.id, path), error: "" }); this.setState({ selectedFileContent: await api.workspaceFile(project.id, workspace.id, path), error: "" });
} catch (error) { } catch (error) {
+11 -2
View File
@@ -1,6 +1,9 @@
import { api } from "../api"; import { api } from "../api";
import { queryNamespace, setNamespacedQueryKey } from "../namespacedQueryArgs";
import type { GetState, SetState, UpdateUrl } from "./types"; import type { GetState, SetState, UpdateUrl } from "./types";
const GIT_ROUTE_NAMESPACE = queryNamespace("core:workspace.git");
export class GitController { export class GitController {
private pollTimer: number | undefined; private pollTimer: number | undefined;
@@ -23,7 +26,7 @@ export class GitController {
if (status.files.some((file) => file.path === selectedDiffPath)) await this.refreshDiff(selectedDiffPath); if (status.files.some((file) => file.path === selectedDiffPath)) await this.refreshDiff(selectedDiffPath);
else { else {
this.setState({ selectedDiffPath: undefined, selectedDiff: undefined, selectedStagedDiff: undefined }); this.setState({ selectedDiffPath: undefined, selectedDiff: undefined, selectedStagedDiff: undefined });
this.updateUrl(); setNamespacedQueryKey(GIT_ROUTE_NAMESPACE, "diff", undefined, { replace: true });
} }
} }
} catch (error) { } catch (error) {
@@ -33,7 +36,13 @@ export class GitController {
async selectDiff(path: string): Promise<void> { async selectDiff(path: string): Promise<void> {
this.setState({ selectedDiffPath: path, selectedDiff: undefined, selectedStagedDiff: undefined, workspaceTool: "core:workspace.git", mainView: this.getState().mainView === "chat" ? "chat" : "core:workspace.git" }); this.setState({ selectedDiffPath: path, selectedDiff: undefined, selectedStagedDiff: undefined, workspaceTool: "core:workspace.git", mainView: this.getState().mainView === "chat" ? "chat" : "core:workspace.git" });
this.updateUrl(); setNamespacedQueryKey(GIT_ROUTE_NAMESPACE, "diff", path);
this.updateUrl({ replace: true });
await this.refreshDiff(path);
}
async restoreDiff(path: string): Promise<void> {
this.setState({ selectedDiffPath: path, selectedDiff: undefined, selectedStagedDiff: undefined });
await this.refreshDiff(path); await this.refreshDiff(path);
} }
+1 -1
View File
@@ -2,7 +2,7 @@ import type { AppState } from "../appState";
export type GetState = () => AppState; export type GetState = () => AppState;
export type SetState = (patch: Partial<AppState>) => void; export type SetState = (patch: Partial<AppState>) => void;
export type UpdateUrl = () => void; export type UpdateUrl = (options?: { replace?: boolean | undefined }) => void;
export interface RouteTarget { export interface RouteTarget {
workspaceId?: string | undefined; workspaceId?: string | undefined;
+67
View File
@@ -0,0 +1,67 @@
export type QueryValue = string | number | boolean | readonly (string | number | boolean)[];
export type QueryValues = Record<string, QueryValue | undefined | null>;
export function queryNamespace(contributionId: string): string {
return contributionId.replaceAll(":", ".");
}
export function readNamespacedQuery(namespace: string): Record<string, string | string[]> {
const params = new URLSearchParams(window.location.search);
const prefix = `${namespace}--`;
const result: Record<string, string | string[]> = {};
for (const [key, value] of params.entries()) {
if (!key.startsWith(prefix)) continue;
const localKey = key.slice(prefix.length);
const existing = result[localKey];
if (existing === undefined) result[localKey] = value;
else if (Array.isArray(existing)) existing.push(value);
else result[localKey] = [existing, value];
}
return result;
}
export function readNamespacedString(namespace: string, key: string): string | undefined {
const value = readNamespacedQuery(namespace)[key];
if (Array.isArray(value)) return value[0];
return value === "" ? undefined : value;
}
export function writeNamespacedQuery(namespace: string, values: QueryValues, options?: { replace?: boolean | undefined }): void {
const url = new URL(window.location.href);
const prefix = `${namespace}--`;
for (const key of Array.from(url.searchParams.keys())) {
if (key.startsWith(prefix)) url.searchParams.delete(key);
}
for (const [key, value] of Object.entries(values)) {
if (value === undefined || value === null || value === "") continue;
const namespacedKey = `${prefix}${key}`;
if (Array.isArray(value)) {
for (const item of value) url.searchParams.append(namespacedKey, String(item));
} else {
url.searchParams.set(namespacedKey, String(value));
}
}
commitUrl(url, options);
}
export function setNamespacedQueryKey(namespace: string, key: string, value: QueryValue | undefined | null, options?: { replace?: boolean | undefined }): void {
const url = new URL(window.location.href);
const namespacedKey = `${namespace}--${key}`;
url.searchParams.delete(namespacedKey);
if (value !== undefined && value !== null && value !== "") {
if (Array.isArray(value)) {
for (const item of value) url.searchParams.append(namespacedKey, String(item));
} else {
url.searchParams.set(namespacedKey, String(value));
}
}
commitUrl(url, options);
}
function commitUrl(url: URL, options?: { replace?: boolean | undefined }): void {
const next = `${url.pathname}${url.search}${url.hash}`;
const current = `${window.location.pathname}${window.location.search}${window.location.hash}`;
if (next === current) return;
if (options?.replace === true) window.history.replaceState({}, "", url);
else window.history.pushState({}, "", url);
}
+6 -7
View File
@@ -22,6 +22,9 @@ function installWindow(href: string): { pushed: string[] } {
pushState: vi.fn((_state: object, _title: string, next: URL | string) => { pushState: vi.fn((_state: object, _title: string, next: URL | string) => {
pushed.push(String(next)); pushed.push(String(next));
}), }),
replaceState: vi.fn((_state: object, _title: string, next: URL | string) => {
pushed.push(String(next));
}),
}, },
}; };
Object.defineProperty(globalThis, "window", { value: fakeWindow, configurable: true }); Object.defineProperty(globalThis, "window", { value: fakeWindow, configurable: true });
@@ -30,7 +33,7 @@ function installWindow(href: string): { pushed: string[] } {
describe("route helpers", () => { describe("route helpers", () => {
it("reads only supported route fields from the current URL", () => { it("reads only supported route fields from the current URL", () => {
installWindow("http://localhost/app?project=p1&workspace=w1&session=s1&tool=git&view=files&file=src%2Fmain.ts&diff=README.md"); installWindow("http://localhost/app?project=p1&workspace=w1&session=s1&tool=git&view=files&core.workspace.files--file=src%2Fmain.ts&core.workspace.git--diff=README.md");
expect(readRoute()).toEqual({ expect(readRoute()).toEqual({
projectId: "p1", projectId: "p1",
@@ -38,8 +41,6 @@ describe("route helpers", () => {
sessionId: "s1", sessionId: "s1",
tool: "core:workspace.git", tool: "core:workspace.git",
view: "core:workspace.files", view: "core:workspace.files",
file: "src/main.ts",
diff: "README.md",
}); });
}); });
@@ -57,19 +58,17 @@ describe("route helpers", () => {
sessionId: "", sessionId: "",
tool: "core:workspace.files", tool: "core:workspace.files",
view: "chat", view: "chat",
file: "src/main.ts",
diff: undefined,
}; };
writeRoute(route); writeRoute(route);
expect(pushed).toEqual(["http://localhost/app?old=1&project=project%2Fid&workspace=workspace+id&tool=core%3Aworkspace.files&view=chat&file=src%2Fmain.ts#section"]); expect(pushed).toEqual(["http://localhost/app?old=1&project=project%2Fid&workspace=workspace+id&tool=core%3Aworkspace.files&view=chat#section"]);
}); });
it("does not push history when the route is unchanged", () => { it("does not push history when the route is unchanged", () => {
const { pushed } = installWindow("http://localhost/app?project=p1&tool=core%3Aworkspace.git"); const { pushed } = installWindow("http://localhost/app?project=p1&tool=core%3Aworkspace.git");
writeRoute({ projectId: "p1", workspaceId: undefined, sessionId: undefined, tool: "core:workspace.git", view: undefined, file: undefined, diff: undefined }); writeRoute({ projectId: "p1", workspaceId: undefined, sessionId: undefined, tool: "core:workspace.git", view: undefined });
expect(pushed).toEqual([]); expect(pushed).toEqual([]);
}); });
+4 -10
View File
@@ -6,8 +6,6 @@ export interface AppRoute {
sessionId: string | undefined; sessionId: string | undefined;
tool: QualifiedContributionId | undefined; tool: QualifiedContributionId | undefined;
view: "chat" | QualifiedContributionId | undefined; view: "chat" | QualifiedContributionId | undefined;
file: string | undefined;
diff: string | undefined;
} }
export function readRoute(): AppRoute { export function readRoute(): AppRoute {
@@ -18,30 +16,26 @@ export function readRoute(): AppRoute {
sessionId: params.get("session") ?? undefined, sessionId: params.get("session") ?? undefined,
tool: parseTool(params.get("tool")), tool: parseTool(params.get("tool")),
view: parseView(params.get("view")), view: parseView(params.get("view")),
file: params.get("file") ?? undefined,
diff: params.get("diff") ?? undefined,
}; };
} }
export function writeRoute(route: AppRoute): void { export function writeRoute(route: AppRoute, options?: { replace?: boolean | undefined }): void {
const url = new URL(window.location.href); const url = new URL(window.location.href);
url.searchParams.delete("project"); url.searchParams.delete("project");
url.searchParams.delete("workspace"); url.searchParams.delete("workspace");
url.searchParams.delete("session"); url.searchParams.delete("session");
url.searchParams.delete("tool"); url.searchParams.delete("tool");
url.searchParams.delete("view"); url.searchParams.delete("view");
url.searchParams.delete("file");
url.searchParams.delete("diff");
if (route.projectId !== undefined && route.projectId !== "") url.searchParams.set("project", route.projectId); if (route.projectId !== undefined && route.projectId !== "") url.searchParams.set("project", route.projectId);
if (route.workspaceId !== undefined && route.workspaceId !== "") url.searchParams.set("workspace", route.workspaceId); if (route.workspaceId !== undefined && route.workspaceId !== "") url.searchParams.set("workspace", route.workspaceId);
if (route.sessionId !== undefined && route.sessionId !== "") url.searchParams.set("session", route.sessionId); if (route.sessionId !== undefined && route.sessionId !== "") url.searchParams.set("session", route.sessionId);
if (route.tool !== undefined) url.searchParams.set("tool", route.tool); if (route.tool !== undefined) url.searchParams.set("tool", route.tool);
if (route.view !== undefined) url.searchParams.set("view", route.view); if (route.view !== undefined) url.searchParams.set("view", route.view);
if (route.file !== undefined && route.file !== "") url.searchParams.set("file", route.file);
if (route.diff !== undefined && route.diff !== "") url.searchParams.set("diff", route.diff);
const next = `${url.pathname}${url.search}${url.hash}`; const next = `${url.pathname}${url.search}${url.hash}`;
const current = `${window.location.pathname}${window.location.search}${window.location.hash}`; const current = `${window.location.pathname}${window.location.search}${window.location.hash}`;
if (next !== current) window.history.pushState({}, "", url); if (next === current) return;
if (options?.replace === true) window.history.replaceState({}, "", url);
else window.history.pushState({}, "", url);
} }
function parseTool(value: string | null): QualifiedContributionId | undefined { function parseTool(value: string | null): QualifiedContributionId | undefined {