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 { 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) {
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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) => {
|
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
@@ -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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user