Add project closing action menu

This commit is contained in:
Federico Jaramillo Martinez
2026-05-08 15:03:38 +02:00
parent 1bbad2e89f
commit 99f434a259
11 changed files with 126 additions and 22 deletions
+2
View File
@@ -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)),
};
+6
View File
@@ -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");
+1 -1
View File
@@ -205,7 +205,7 @@ export class PiWebApp extends LitElement {
<strong>Pi Web POC</strong>
<button @click=${() => { this.setState({ projectDialogOpen: true }); }}>+ Project</button>
</header>
<project-list .projects=${state.projects} .selected=${state.selectedProject} .onSelect=${(project: Project) => this.withChatScrollTransition(() => this.workspaces.selectProject(project))}></project-list>
<project-list .projects=${state.projects} .selected=${state.selectedProject} .onSelect=${(project: Project) => this.withChatScrollTransition(() => this.workspaces.selectProject(project))} .onClose=${(project: Project) => this.projects.closeProject(project.id)}></project-list>
<workspace-list .workspaces=${state.workspaces} .selected=${state.selectedWorkspace} .onSelect=${(workspace: Workspace) => this.withChatScrollTransition(() => this.workspaces.selectWorkspace(workspace))}></workspace-list>
<session-list .sessions=${state.sessions} .statuses=${state.sessionStatuses} .activities=${state.sessionActivities} .selected=${state.selectedSession} .canStart=${!!state.selectedWorkspace} .onStart=${() => 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)}></session-list>
</aside>
+53 -5
View File
@@ -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<this>): void {
if (changed.has("projects") && this.openMenuProjectId !== undefined && !this.projects.some((project) => project.id === this.openMenuProjectId)) this.openMenuProjectId = undefined;
}
override render() {
return html`
<section>
<h2>Projects</h2>
${this.projects.map((project) => html`
<button class=${this.selected?.id === project.id ? "selected" : ""} @click=${() => this.onSelect?.(project)}>
<span>${project.name}</span><small>${project.path}</small>
</button>
<div class=${`action-row ${this.selected?.id === project.id ? "selected" : ""}`}>
<button class="action-main" @click=${() => this.onSelect?.(project)}>
<span>${project.name}</span><small>${project.path}</small>
</button>
<div class="action-menu">
<button class="action-menu-toggle" title="Project actions" aria-label=${`Actions for ${project.name}`} @click=${(event: MouseEvent) => { event.stopPropagation(); this.toggleMenu(project.id, event.currentTarget); }}>⋯</button>
${this.openMenuProjectId === project.id ? html`
<div class="action-menu-panel" style=${this.menuStyle}>
<button title="Close project" @click=${() => { this.close(project); }}>Close</button>
</div>
` : null}
</div>
</div>
`)}
</section>
`;
}
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;
}
+16 -7
View File
@@ -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`
<div class="session-row ${this.selected?.id === session.id ? "selected" : ""} ${session.archived === true ? "archived" : ""}">
<button class="session-main" @click=${() => this.onSelect?.(session)}>
<div class="action-row ${this.selected?.id === session.id ? "selected" : ""} ${session.archived === true ? "archived" : ""}">
<button class="action-main" @click=${() => this.onSelect?.(session)}>
<span>${sessionLabel(session)}</span><small>${this.renderStatus(session)}${String(session.messageCount)} messages</small>
</button>
<div class="session-menu">
<button class="session-menu-toggle" title="Session actions" @click=${(event: MouseEvent) => { event.stopPropagation(); this.toggleMenu(session.id); }}>⋯</button>
<div class="action-menu">
<button class="action-menu-toggle" title="Session actions" @click=${(event: MouseEvent) => { event.stopPropagation(); this.toggleMenu(session.id, event.currentTarget); }}>⋯</button>
${this.openMenuSessionId === session.id ? html`
<div class="session-menu-panel">
<div class="action-menu-panel" style=${this.menuStyle}>
${session.archived === true
? html`<button title="Restore session" @click=${() => { this.openMenuSessionId = undefined; this.onRestore?.(session); }}>Restore</button>`
: html`<button title="Archive session" @click=${() => { this.openMenuSessionId = undefined; this.onArchive?.(session); }}>Archive</button>`}
@@ -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() {
+10 -9
View File
@@ -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; }
@@ -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) });
}
}
}
@@ -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: "" });
+9
View File
@@ -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 ?? "");
+4
View File
@@ -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<void> {
if (!(await this.store.remove(id))) throw new Error("Project not found");
}
async requireProject(id: string): Promise<Project> {
const project = await this.store.get(id);
if (!project) throw new Error("Project not found");
+8
View File
@@ -61,6 +61,14 @@ export class ProjectStore {
return (await this.list()).find((p) => p.id === id);
}
async remove(id: string): Promise<boolean> {
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<ProjectFile> {
try {
const value: unknown = JSON.parse(await readFile(this.filePath, "utf8"));