From 99f434a259ed8e33a8b4f71eb70074a3209582bf Mon Sep 17 00:00:00 2001 From: Federico Jaramillo Martinez Date: Fri, 8 May 2026 15:03:38 +0200 Subject: [PATCH] Add project closing action menu --- src/client/src/api/clients.ts | 2 + src/client/src/api/parsers.ts | 6 ++ src/client/src/components/PiWebApp.ts | 2 +- src/client/src/components/ProjectList.ts | 58 +++++++++++++++++-- src/client/src/components/SessionList.ts | 23 +++++--- src/client/src/components/shared.ts | 19 +++--- .../src/controllers/projectController.ts | 11 ++++ .../src/controllers/workspaceController.ts | 6 ++ src/server/index.ts | 9 +++ src/server/projects/projectService.ts | 4 ++ src/server/storage/projectStore.ts | 8 +++ 11 files changed, 126 insertions(+), 22 deletions(-) diff --git a/src/client/src/api/clients.ts b/src/client/src/api/clients.ts index 22588d5..4bd46c0 100644 --- a/src/client/src/api/clients.ts +++ b/src/client/src/api/clients.ts @@ -5,6 +5,7 @@ import { parseAborted, parseAccepted, parseArchived, + parseClosed, parseCommandResult, parseFileContentResponse, parseFileSuggestion, @@ -25,6 +26,7 @@ import { gitDiffUrl, messageUrl } from "./urls"; export const projectsApi = { projects: () => request("/api/projects", arrayOf(parseProject)), addProject: (path: string, name?: string, create?: boolean) => request("/api/projects", parseProject, { method: "POST", body: JSON.stringify({ path, name, create }) }), + closeProject: (projectId: string) => request(`/api/projects/${encodeURIComponent(projectId)}`, parseClosed, { method: "DELETE" }), projectDirectories: (query: string) => request(`/api/project-directories?q=${encodeURIComponent(query)}`, arrayOf(parseFileSuggestion)), }; diff --git a/src/client/src/api/parsers.ts b/src/client/src/api/parsers.ts index 9bc3220..5f750d9 100644 --- a/src/client/src/api/parsers.ts +++ b/src/client/src/api/parsers.ts @@ -217,6 +217,12 @@ export function parseAccepted(value: unknown): { accepted: true } { return { accepted: true }; } +export function parseClosed(value: unknown): { closed: true } { + const record = requireRecord(value); + if (record["closed"] !== true) throw new Error("Expected closed response"); + return { closed: true }; +} + export function parseAborted(value: unknown): { aborted: true } { const record = requireRecord(value); if (record["aborted"] !== true) throw new Error("Expected aborted response"); diff --git a/src/client/src/components/PiWebApp.ts b/src/client/src/components/PiWebApp.ts index 960320a..fa8bdbe 100644 --- a/src/client/src/components/PiWebApp.ts +++ b/src/client/src/components/PiWebApp.ts @@ -205,7 +205,7 @@ export class PiWebApp extends LitElement { Pi Web POC - this.withChatScrollTransition(() => this.workspaces.selectProject(project))}> + this.withChatScrollTransition(() => this.workspaces.selectProject(project))} .onClose=${(project: Project) => this.projects.closeProject(project.id)}> this.withChatScrollTransition(() => this.workspaces.selectWorkspace(workspace))}> this.withChatScrollTransition(() => this.sessions.startSession())} .onSelect=${(session: SessionInfo) => this.withChatScrollTransition(() => this.sessions.selectSession(session))} .onArchive=${(session: SessionInfo) => this.sessions.archiveSession(session)} .onRestore=${(session: SessionInfo) => this.sessions.restoreSession(session)}> diff --git a/src/client/src/components/ProjectList.ts b/src/client/src/components/ProjectList.ts index fbd07c4..e86e465 100644 --- a/src/client/src/components/ProjectList.ts +++ b/src/client/src/components/ProjectList.ts @@ -1,5 +1,5 @@ -import { LitElement, html } from "lit"; -import { customElement, property } from "lit/decorators.js"; +import { LitElement, html, type PropertyValues } from "lit"; +import { customElement, property, state } from "lit/decorators.js"; import type { Project } from "../api"; import { listStyles } from "./shared"; @@ -8,19 +8,67 @@ export class ProjectList extends LitElement { @property({ attribute: false }) projects: Project[] = []; @property({ attribute: false }) selected?: Project; @property({ attribute: false }) onSelect?: (project: Project) => void; + @property({ attribute: false }) onClose?: (project: Project) => void; + @state() private openMenuProjectId: string | undefined; + @state() private menuStyle = ""; + private readonly onDocumentClick = (event: MouseEvent) => { + if (event.composedPath().includes(this)) return; + this.openMenuProjectId = undefined; + }; + + override connectedCallback(): void { + super.connectedCallback(); + document.addEventListener("click", this.onDocumentClick); + } + + override disconnectedCallback(): void { + document.removeEventListener("click", this.onDocumentClick); + super.disconnectedCallback(); + } + + protected override updated(changed: PropertyValues): void { + if (changed.has("projects") && this.openMenuProjectId !== undefined && !this.projects.some((project) => project.id === this.openMenuProjectId)) this.openMenuProjectId = undefined; + } override render() { return html`

Projects

${this.projects.map((project) => html` - +
+ +
+ + ${this.openMenuProjectId === project.id ? html` +
+ +
+ ` : null} +
+
`)}
`; } + private toggleMenu(projectId: string, target: EventTarget | null) { + if (this.openMenuProjectId === projectId) { + this.openMenuProjectId = undefined; + return; + } + if (target instanceof HTMLElement) { + const rect = target.getBoundingClientRect(); + this.menuStyle = `top: ${String(rect.bottom + 4)}px; right: ${String(window.innerWidth - rect.right)}px;`; + } + this.openMenuProjectId = projectId; + } + + private close(project: Project) { + this.openMenuProjectId = undefined; + if (confirm(`Close ${project.name}?\n\nThis only removes it from Pi Web; it will not change the project folder.`)) this.onClose?.(project); + } + static override styles = listStyles; } diff --git a/src/client/src/components/SessionList.ts b/src/client/src/components/SessionList.ts index 0c54dd2..fb4e34e 100644 --- a/src/client/src/components/SessionList.ts +++ b/src/client/src/components/SessionList.ts @@ -18,6 +18,7 @@ export class SessionList extends LitElement { @property({ attribute: false }) onSelect?: (session: SessionInfo) => void; @property({ attribute: false }) onStart?: () => void; @state() private openMenuSessionId: string | undefined; + @state() private menuStyle = ""; @state() private archivedExpanded = false; private readonly onDocumentClick = (event: MouseEvent) => { if (event.composedPath().includes(this)) return; @@ -58,14 +59,14 @@ export class SessionList extends LitElement { private renderSession(session: SessionInfo) { return html` -
- -
- +
+ ${this.openMenuSessionId === session.id ? html` -
+
${session.archived === true ? html`` : html``} @@ -76,8 +77,16 @@ export class SessionList extends LitElement { `; } - private toggleMenu(sessionId: string) { - this.openMenuSessionId = this.openMenuSessionId === sessionId ? undefined : sessionId; + private toggleMenu(sessionId: string, target: EventTarget | null) { + if (this.openMenuSessionId === sessionId) { + this.openMenuSessionId = undefined; + return; + } + if (target instanceof HTMLElement) { + const rect = target.getBoundingClientRect(); + this.menuStyle = `top: ${String(rect.bottom + 4)}px; right: ${String(window.innerWidth - rect.right)}px;`; + } + this.openMenuSessionId = sessionId; } private toggleArchived() { diff --git a/src/client/src/components/shared.ts b/src/client/src/components/shared.ts index 04943b2..f779f4e 100644 --- a/src/client/src/components/shared.ts +++ b/src/client/src/components/shared.ts @@ -93,15 +93,16 @@ export const listStyles = css` .subheading { margin-top: 14px; } .section-toggle { display: flex; align-items: center; justify-content: space-between; gap: 8px; width: 100%; border: 0; background: transparent; color: inherit; padding: 0; font: inherit; text-transform: inherit; } .section-toggle small { display: inline; color: inherit; font-size: inherit; } - .session-row { position: relative; display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 6px; margin: 6px 0; } - .session-row.selected .session-main { border-color: #58a6ff; background: #0d2847; } - .session-row.archived .session-main { color: #8b949e; } - .session-main { min-width: 0; text-align: left; } - .session-menu { position: relative; align-self: stretch; } - .session-menu-toggle { display: grid; place-items: center; height: 100%; min-width: 30px; padding: 0; color: #8b949e; } - .session-menu-panel { position: absolute; right: 0; top: calc(100% + 4px); z-index: 5; min-width: 120px; padding: 4px; border: 1px solid #30363d; border-radius: 8px; background: #161b22; box-shadow: 0 8px 24px #0008; } - .session-menu-panel button { display: block; width: 100%; text-align: left; border: 0; background: transparent; color: #e6edf3; } - .session-menu-panel button:hover { background: #0d2847; } + .action-row { position: relative; display: grid; grid-template-columns: minmax(0, 1fr) auto; margin: 6px 0; } + .action-row.selected .action-main, .action-row.selected .action-menu-toggle { border-color: #58a6ff; background: #0d2847; } + .action-row.archived .action-main { color: #8b949e; } + .action-main { min-width: 0; text-align: left; border-top-right-radius: 0; border-bottom-right-radius: 0; } + .action-menu { position: relative; align-self: stretch; } + .action-menu-toggle { display: grid; place-items: center; height: 100%; min-width: 32px; padding: 0; color: #8b949e; border-left: 0; border-top-left-radius: 0; border-bottom-left-radius: 0; } + .action-menu-toggle:hover { color: #e6edf3; background: #21262d; } + .action-menu-panel { position: fixed; z-index: 50; min-width: 120px; padding: 4px; border: 1px solid #30363d; border-radius: 8px; background: #161b22; box-shadow: 0 8px 24px #0008; } + .action-menu-panel button { display: block; width: 100%; text-align: left; border: 0; background: transparent; color: #e6edf3; } + .action-menu-panel button:hover { background: #0d2847; } button.selected { border-color: #58a6ff; background: #0d2847; } button:disabled { opacity: .5; cursor: not-allowed; } small { display: block; color: #8b949e; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } diff --git a/src/client/src/controllers/projectController.ts b/src/client/src/controllers/projectController.ts index 4937a22..c5b8dae 100644 --- a/src/client/src/controllers/projectController.ts +++ b/src/client/src/controllers/projectController.ts @@ -25,4 +25,15 @@ export class ProjectController { this.setState({ error: String(error) }); } } + + async closeProject(projectId: string) { + try { + await api.closeProject(projectId); + const state = this.getState(); + this.setState({ projects: state.projects.filter((p) => p.id !== projectId) }); + if (state.selectedProject?.id === projectId) this.workspaces.clearSelection(); + } catch (error) { + this.setState({ error: String(error) }); + } + } } diff --git a/src/client/src/controllers/workspaceController.ts b/src/client/src/controllers/workspaceController.ts index 96096dd..7b7feee 100644 --- a/src/client/src/controllers/workspaceController.ts +++ b/src/client/src/controllers/workspaceController.ts @@ -10,6 +10,12 @@ export class WorkspaceController { private readonly sessions: SessionController, ) {} + clearSelection(options?: { updateUrl?: boolean | undefined }) { + this.sessions.clearActiveSession(); + this.setState({ selectedProject: undefined, selectedWorkspace: undefined, sessions: [], workspaces: [], fileTree: [], expandedDirs: {}, selectedFilePath: undefined, selectedFileContent: undefined, fileTreeStale: false, gitStatus: undefined, selectedDiffPath: undefined, selectedDiff: undefined, selectedStagedDiff: undefined, gitStale: false, error: "" }); + if (options?.updateUrl !== false) this.updateUrl(); + } + async selectProject(project: Project, target?: RouteTarget) { this.sessions.clearActiveSession(); this.setState({ selectedProject: project, selectedWorkspace: undefined, sessions: [], workspaces: [], fileTree: [], expandedDirs: {}, selectedFilePath: undefined, selectedFileContent: undefined, fileTreeStale: false, gitStatus: undefined, selectedDiffPath: undefined, selectedDiff: undefined, selectedStagedDiff: undefined, gitStale: false, error: "" }); diff --git a/src/server/index.ts b/src/server/index.ts index 7c6fae2..42b1c79 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -28,6 +28,15 @@ app.post<{ Body: { name?: string; path: string; create?: boolean } }>("/api/proj } }); +app.delete<{ Params: { projectId: string } }>("/api/projects/:projectId", async (request, reply) => { + try { + await projects.close(request.params.projectId); + return { closed: true }; + } catch (error) { + return reply.code(404).send({ error: error instanceof Error ? error.message : String(error) }); + } +}); + app.get<{ Querystring: { q?: string } }>("/api/project-directories", async (request, reply) => { try { return await listDirectorySuggestions(request.query.q ?? ""); diff --git a/src/server/projects/projectService.ts b/src/server/projects/projectService.ts index 52873c1..4169540 100644 --- a/src/server/projects/projectService.ts +++ b/src/server/projects/projectService.ts @@ -19,6 +19,10 @@ export class ProjectService { return this.store.add(input.name === undefined ? { path: resolved } : { name: input.name, path: resolved }); } + async close(id: string): Promise { + if (!(await this.store.remove(id))) throw new Error("Project not found"); + } + async requireProject(id: string): Promise { const project = await this.store.get(id); if (!project) throw new Error("Project not found"); diff --git a/src/server/storage/projectStore.ts b/src/server/storage/projectStore.ts index b1b8d0f..890585c 100644 --- a/src/server/storage/projectStore.ts +++ b/src/server/storage/projectStore.ts @@ -61,6 +61,14 @@ export class ProjectStore { return (await this.list()).find((p) => p.id === id); } + async remove(id: string): Promise { + const data = await this.read(); + const projects = data.projects.filter((p) => p.id !== id); + if (projects.length === data.projects.length) return false; + await this.write({ projects }); + return true; + } + private async read(): Promise { try { const value: unknown = JSON.parse(await readFile(this.filePath, "utf8"));