From 06fde71f9260fa78f7c0187bf114b510898ac863 Mon Sep 17 00:00:00 2001 From: Federico Jaramillo Martinez Date: Sat, 9 May 2026 22:28:47 +0200 Subject: [PATCH] Namespace panel query state --- src/client/src/components/PiWebApp.ts | 15 +++-- .../src/controllers/fileExplorerController.ts | 13 +++- src/client/src/controllers/gitController.ts | 13 +++- src/client/src/controllers/types.ts | 2 +- src/client/src/namespacedQueryArgs.ts | 67 +++++++++++++++++++ src/client/src/route.test.ts | 13 ++-- src/client/src/route.ts | 14 ++-- 7 files changed, 108 insertions(+), 29 deletions(-) create mode 100644 src/client/src/namespacedQueryArgs.ts diff --git a/src/client/src/components/PiWebApp.ts b/src/client/src/components/PiWebApp.ts index 035221f..f118df4 100644 --- a/src/client/src/components/PiWebApp.ts +++ b/src/client/src/components/PiWebApp.ts @@ -13,6 +13,7 @@ import type { QualifiedContributionId, QualifiedWorkspacePanelContribution, Plug import { corePlugin } from "../plugins/core"; import { examplePlugin } from "../plugins/example"; import { PluginRegistry } from "../plugins/registry"; +import { queryNamespace, readNamespacedString } from "../namespacedQueryArgs"; import { readRoute, writeRoute } from "../route"; import "./ProjectList"; import "./WorkspaceList"; @@ -101,15 +102,17 @@ export class PiWebApp extends LitElement { private async restoreRoute(updateUrl: boolean) { 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; 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 }); 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.diff !== undefined) await this.git.selectDiff(route.diff); this.git.updatePolling(); } @@ -132,16 +135,14 @@ export class PiWebApp extends LitElement { this.chatView?.restorePrependScrollAnchor(anchor); } - private updateUrl() { + private updateUrl(options?: { replace?: boolean | undefined }) { writeRoute({ projectId: this.state.selectedProject?.id, workspaceId: this.state.selectedWorkspace?.id, sessionId: this.state.selectedSession?.id, tool: this.state.workspaceTool, view: this.state.mainView === "navigation" ? undefined : this.state.mainView, - file: this.state.selectedFilePath, - diff: this.state.selectedDiffPath, - }); + }, options); } private selectWorkspaceTool(tool: QualifiedContributionId) { diff --git a/src/client/src/controllers/fileExplorerController.ts b/src/client/src/controllers/fileExplorerController.ts index 5967233..9574db4 100644 --- a/src/client/src/controllers/fileExplorerController.ts +++ b/src/client/src/controllers/fileExplorerController.ts @@ -1,6 +1,9 @@ import { api } from "../api"; +import { queryNamespace, setNamespacedQueryKey } from "../namespacedQueryArgs"; import type { GetState, SetState, UpdateUrl } from "./types"; +const FILES_ROUTE_NAMESPACE = queryNamespace("core:workspace.files"); + export class FileExplorerController { 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 { + 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 { const project = this.getState().selectedProject; const workspace = this.getState().selectedWorkspace; 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.updateUrl(); + this.setState({ selectedFilePath: path, selectedFileContent: undefined }); try { this.setState({ selectedFileContent: await api.workspaceFile(project.id, workspace.id, path), error: "" }); } catch (error) { diff --git a/src/client/src/controllers/gitController.ts b/src/client/src/controllers/gitController.ts index 4c0712e..c1a1409 100644 --- a/src/client/src/controllers/gitController.ts +++ b/src/client/src/controllers/gitController.ts @@ -1,6 +1,9 @@ import { api } from "../api"; +import { queryNamespace, setNamespacedQueryKey } from "../namespacedQueryArgs"; import type { GetState, SetState, UpdateUrl } from "./types"; +const GIT_ROUTE_NAMESPACE = queryNamespace("core:workspace.git"); + export class GitController { private pollTimer: number | undefined; @@ -23,7 +26,7 @@ export class GitController { if (status.files.some((file) => file.path === selectedDiffPath)) await this.refreshDiff(selectedDiffPath); else { this.setState({ selectedDiffPath: undefined, selectedDiff: undefined, selectedStagedDiff: undefined }); - this.updateUrl(); + setNamespacedQueryKey(GIT_ROUTE_NAMESPACE, "diff", undefined, { replace: true }); } } } catch (error) { @@ -33,7 +36,13 @@ export class GitController { async selectDiff(path: string): Promise { 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 { + this.setState({ selectedDiffPath: path, selectedDiff: undefined, selectedStagedDiff: undefined }); await this.refreshDiff(path); } diff --git a/src/client/src/controllers/types.ts b/src/client/src/controllers/types.ts index 2487377..1f64ccb 100644 --- a/src/client/src/controllers/types.ts +++ b/src/client/src/controllers/types.ts @@ -2,7 +2,7 @@ import type { AppState } from "../appState"; export type GetState = () => AppState; export type SetState = (patch: Partial) => void; -export type UpdateUrl = () => void; +export type UpdateUrl = (options?: { replace?: boolean | undefined }) => void; export interface RouteTarget { workspaceId?: string | undefined; diff --git a/src/client/src/namespacedQueryArgs.ts b/src/client/src/namespacedQueryArgs.ts new file mode 100644 index 0000000..701b551 --- /dev/null +++ b/src/client/src/namespacedQueryArgs.ts @@ -0,0 +1,67 @@ +export type QueryValue = string | number | boolean | readonly (string | number | boolean)[]; +export type QueryValues = Record; + +export function queryNamespace(contributionId: string): string { + return contributionId.replaceAll(":", "."); +} + +export function readNamespacedQuery(namespace: string): Record { + const params = new URLSearchParams(window.location.search); + const prefix = `${namespace}--`; + const result: Record = {}; + 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); +} diff --git a/src/client/src/route.test.ts b/src/client/src/route.test.ts index c02bebf..24ffd7e 100644 --- a/src/client/src/route.test.ts +++ b/src/client/src/route.test.ts @@ -22,6 +22,9 @@ function installWindow(href: string): { pushed: string[] } { pushState: vi.fn((_state: object, _title: string, next: URL | string) => { 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 }); @@ -30,7 +33,7 @@ function installWindow(href: string): { pushed: string[] } { describe("route helpers", () => { 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({ projectId: "p1", @@ -38,8 +41,6 @@ describe("route helpers", () => { sessionId: "s1", tool: "core:workspace.git", view: "core:workspace.files", - file: "src/main.ts", - diff: "README.md", }); }); @@ -57,19 +58,17 @@ describe("route helpers", () => { sessionId: "", tool: "core:workspace.files", view: "chat", - file: "src/main.ts", - diff: undefined, }; 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", () => { 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([]); }); diff --git a/src/client/src/route.ts b/src/client/src/route.ts index 84e325c..bacb35e 100644 --- a/src/client/src/route.ts +++ b/src/client/src/route.ts @@ -6,8 +6,6 @@ export interface AppRoute { sessionId: string | undefined; tool: QualifiedContributionId | undefined; view: "chat" | QualifiedContributionId | undefined; - file: string | undefined; - diff: string | undefined; } export function readRoute(): AppRoute { @@ -18,30 +16,26 @@ export function readRoute(): AppRoute { sessionId: params.get("session") ?? undefined, tool: parseTool(params.get("tool")), 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); url.searchParams.delete("project"); url.searchParams.delete("workspace"); url.searchParams.delete("session"); url.searchParams.delete("tool"); 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.workspaceId !== undefined && route.workspaceId !== "") url.searchParams.set("workspace", route.workspaceId); if (route.sessionId !== undefined && route.sessionId !== "") url.searchParams.set("session", route.sessionId); if (route.tool !== undefined) url.searchParams.set("tool", route.tool); 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 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 {