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 { 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) {
@@ -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<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 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) {
+11 -2
View File
@@ -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<void> {
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);
}
+1 -1
View File
@@ -2,7 +2,7 @@ import type { AppState } from "../appState";
export type GetState = () => AppState;
export type SetState = (patch: Partial<AppState>) => void;
export type UpdateUrl = () => void;
export type UpdateUrl = (options?: { replace?: boolean | undefined }) => void;
export interface RouteTarget {
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) => {
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([]);
});
+4 -10
View File
@@ -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 {