Archived
Namespace panel query state
This commit is contained in:
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user