Archived
Improve project folder creation
This commit is contained in:
@@ -24,7 +24,8 @@ import { gitDiffUrl, messageUrl } from "./urls";
|
|||||||
|
|
||||||
export const projectsApi = {
|
export const projectsApi = {
|
||||||
projects: () => request("/api/projects", arrayOf(parseProject)),
|
projects: () => request("/api/projects", arrayOf(parseProject)),
|
||||||
addProject: (path: string, name?: string) => request("/api/projects", parseProject, { method: "POST", body: JSON.stringify({ path, name }) }),
|
addProject: (path: string, name?: string, create?: boolean) => request("/api/projects", parseProject, { method: "POST", body: JSON.stringify({ path, name, create }) }),
|
||||||
|
projectDirectories: (query: string) => request(`/api/project-directories?q=${encodeURIComponent(query)}`, arrayOf(parseFileSuggestion)),
|
||||||
};
|
};
|
||||||
|
|
||||||
export const workspacesApi = {
|
export const workspacesApi = {
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ export interface AppState {
|
|||||||
sessionActivities: Record<string, SessionActivity>;
|
sessionActivities: Record<string, SessionActivity>;
|
||||||
commandDialog: Extract<CommandResult, { type: "select" }> | undefined;
|
commandDialog: Extract<CommandResult, { type: "select" }> | undefined;
|
||||||
actionPaletteOpen: boolean;
|
actionPaletteOpen: boolean;
|
||||||
|
projectDialogOpen: boolean;
|
||||||
workspaceTool: "files" | "git";
|
workspaceTool: "files" | "git";
|
||||||
mainView: "chat" | "files" | "git";
|
mainView: "chat" | "files" | "git";
|
||||||
fileTree: FileTreeEntry[];
|
fileTree: FileTreeEntry[];
|
||||||
@@ -51,6 +52,7 @@ export function initialAppState(): AppState {
|
|||||||
sessionActivities: {},
|
sessionActivities: {},
|
||||||
commandDialog: undefined,
|
commandDialog: undefined,
|
||||||
actionPaletteOpen: false,
|
actionPaletteOpen: false,
|
||||||
|
projectDialogOpen: false,
|
||||||
workspaceTool: "files",
|
workspaceTool: "files",
|
||||||
mainView: "chat",
|
mainView: "chat",
|
||||||
fileTree: [],
|
fileTree: [],
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import type { PromptEditor } from "./PromptEditor";
|
|||||||
import "./StatusBar";
|
import "./StatusBar";
|
||||||
import "./CommandPicker";
|
import "./CommandPicker";
|
||||||
import "./ActionPalette";
|
import "./ActionPalette";
|
||||||
|
import "./ProjectDialog";
|
||||||
import "./WorkspacePanel";
|
import "./WorkspacePanel";
|
||||||
import { appStyles } from "./shared";
|
import { appStyles } from "./shared";
|
||||||
|
|
||||||
@@ -181,7 +182,7 @@ export class PiWebApp extends LitElement {
|
|||||||
state: this.state,
|
state: this.state,
|
||||||
openActionPalette: () => { this.setState({ actionPaletteOpen: true }); },
|
openActionPalette: () => { this.setState({ actionPaletteOpen: true }); },
|
||||||
focusPrompt: () => { this.promptEditor?.focusInput(); },
|
focusPrompt: () => { this.promptEditor?.focusInput(); },
|
||||||
addProject: () => this.projects.addProject(),
|
addProject: () => { this.setState({ projectDialogOpen: true }); },
|
||||||
selectMainView: (view) => { this.selectMainView(view); },
|
selectMainView: (view) => { this.selectMainView(view); },
|
||||||
refreshFiles: () => this.files.refreshFiles(),
|
refreshFiles: () => this.files.refreshFiles(),
|
||||||
refreshGit: () => this.git.refreshGit(),
|
refreshGit: () => this.git.refreshGit(),
|
||||||
@@ -202,7 +203,7 @@ export class PiWebApp extends LitElement {
|
|||||||
<aside>
|
<aside>
|
||||||
<header>
|
<header>
|
||||||
<strong>Pi Web POC</strong>
|
<strong>Pi Web POC</strong>
|
||||||
<button @click=${() => this.projects.addProject()}>+ Project</button>
|
<button @click=${() => { this.setState({ projectDialogOpen: true }); }}>+ Project</button>
|
||||||
</header>
|
</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))}></project-list>
|
||||||
<workspace-list .workspaces=${state.workspaces} .selected=${state.selectedWorkspace} .onSelect=${(workspace: Workspace) => this.withChatScrollTransition(() => this.workspaces.selectWorkspace(workspace))}></workspace-list>
|
<workspace-list .workspaces=${state.workspaces} .selected=${state.selectedWorkspace} .onSelect=${(workspace: Workspace) => this.withChatScrollTransition(() => this.workspaces.selectWorkspace(workspace))}></workspace-list>
|
||||||
@@ -225,6 +226,7 @@ export class PiWebApp extends LitElement {
|
|||||||
</main>
|
</main>
|
||||||
${this.renderWorkspacePanel()}
|
${this.renderWorkspacePanel()}
|
||||||
${state.actionPaletteOpen ? html`<action-palette .actions=${this.getActions()} .onRun=${(actionId: string) => { this.setState({ actionPaletteOpen: false }); this.runAction(actionId); }} .onCancel=${() => { this.setState({ actionPaletteOpen: false }); }}></action-palette>` : null}
|
${state.actionPaletteOpen ? html`<action-palette .actions=${this.getActions()} .onRun=${(actionId: string) => { this.setState({ actionPaletteOpen: false }); this.runAction(actionId); }} .onCancel=${() => { this.setState({ actionPaletteOpen: false }); }}></action-palette>` : null}
|
||||||
|
${state.projectDialogOpen ? html`<project-dialog .onSubmit=${(path: string, create: boolean) => this.projects.addProject(path, create)} .onCancel=${() => { this.setState({ projectDialogOpen: false }); }}></project-dialog>` : null}
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,139 @@
|
|||||||
|
import { LitElement, html } from "lit";
|
||||||
|
import { customElement, property, state } from "lit/decorators.js";
|
||||||
|
import { api, type FileSuggestion } from "../api";
|
||||||
|
import { css } from "lit";
|
||||||
|
|
||||||
|
@customElement("project-dialog")
|
||||||
|
export class ProjectDialog extends LitElement {
|
||||||
|
@property({ attribute: false }) onSubmit?: (path: string, create: boolean) => void;
|
||||||
|
@property({ attribute: false }) onCancel?: () => void;
|
||||||
|
@state() private path = "";
|
||||||
|
@state() private createMissing = true;
|
||||||
|
@state() private suggestions: FileSuggestion[] = [];
|
||||||
|
@state() private selected = 0;
|
||||||
|
@state() private loading = false;
|
||||||
|
|
||||||
|
private requestId = 0;
|
||||||
|
|
||||||
|
override connectedCallback(): void {
|
||||||
|
super.connectedCallback();
|
||||||
|
void this.loadSuggestions();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async loadSuggestions() {
|
||||||
|
const requestId = ++this.requestId;
|
||||||
|
this.loading = true;
|
||||||
|
try {
|
||||||
|
const suggestions = await api.projectDirectories(this.path);
|
||||||
|
if (requestId !== this.requestId) return;
|
||||||
|
this.suggestions = suggestions;
|
||||||
|
this.selected = Math.min(this.selected, Math.max(0, suggestions.length - 1));
|
||||||
|
} catch {
|
||||||
|
if (requestId === this.requestId) this.suggestions = [];
|
||||||
|
} finally {
|
||||||
|
if (requestId === this.requestId) this.loading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private setPath(value: string) {
|
||||||
|
this.path = value;
|
||||||
|
this.selected = 0;
|
||||||
|
void this.loadSuggestions();
|
||||||
|
}
|
||||||
|
|
||||||
|
private pick(suggestion: FileSuggestion) {
|
||||||
|
this.setPath(suggestion.path);
|
||||||
|
}
|
||||||
|
|
||||||
|
private submit() {
|
||||||
|
if (this.path.trim() === "") return;
|
||||||
|
this.onSubmit?.(this.path, this.createMissing);
|
||||||
|
}
|
||||||
|
|
||||||
|
private onPathInput(event: InputEvent) {
|
||||||
|
if (!(event.target instanceof HTMLInputElement)) return;
|
||||||
|
this.setPath(event.target.value);
|
||||||
|
}
|
||||||
|
|
||||||
|
private onCreateMissingChange(event: InputEvent) {
|
||||||
|
if (!(event.target instanceof HTMLInputElement)) return;
|
||||||
|
this.createMissing = event.target.checked;
|
||||||
|
}
|
||||||
|
|
||||||
|
private onKeyDown(event: KeyboardEvent) {
|
||||||
|
if (event.key === "Escape") {
|
||||||
|
event.preventDefault();
|
||||||
|
this.onCancel?.();
|
||||||
|
} else if (event.key === "Enter") {
|
||||||
|
event.preventDefault();
|
||||||
|
this.submit();
|
||||||
|
} else if (event.key === "ArrowDown") {
|
||||||
|
event.preventDefault();
|
||||||
|
this.selected = Math.min(this.selected + 1, Math.max(0, this.suggestions.length - 1));
|
||||||
|
} else if (event.key === "ArrowUp") {
|
||||||
|
event.preventDefault();
|
||||||
|
this.selected = Math.max(0, this.selected - 1);
|
||||||
|
} else if (event.key === "Tab") {
|
||||||
|
const suggestion = this.suggestions[this.selected];
|
||||||
|
if (suggestion === undefined) return;
|
||||||
|
event.preventDefault();
|
||||||
|
this.pick(suggestion);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override render() {
|
||||||
|
return html`
|
||||||
|
<div class="backdrop" @click=${() => this.onCancel?.()}>
|
||||||
|
<section @click=${(event: Event) => { event.stopPropagation(); }}>
|
||||||
|
<header>
|
||||||
|
<strong>Add project</strong>
|
||||||
|
<button @click=${() => { this.onCancel?.(); }} aria-label="Close">×</button>
|
||||||
|
</header>
|
||||||
|
<div class="body">
|
||||||
|
<label>
|
||||||
|
Project folder
|
||||||
|
<input .value=${this.path} @input=${(event: InputEvent) => { this.onPathInput(event); }} @keydown=${(event: KeyboardEvent) => { this.onKeyDown(event); }} placeholder="/path/to/project or ~/code/project" autofocus />
|
||||||
|
</label>
|
||||||
|
<div class="suggestions">
|
||||||
|
${this.loading ? html`<div class="hint">Loading folders…</div>` : null}
|
||||||
|
${this.suggestions.map((suggestion, index) => html`
|
||||||
|
<button class=${index === this.selected ? "selected" : ""} @click=${() => { this.pick(suggestion); }}>
|
||||||
|
${suggestion.path}
|
||||||
|
</button>
|
||||||
|
`)}
|
||||||
|
${!this.loading && this.suggestions.length === 0 ? html`<div class="hint">No matching folders. Enter a new path to create it.</div>` : null}
|
||||||
|
</div>
|
||||||
|
<label class="check">
|
||||||
|
<input type="checkbox" .checked=${this.createMissing} @change=${(event: InputEvent) => { this.onCreateMissingChange(event); }} />
|
||||||
|
Create the folder if it does not exist
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<footer>
|
||||||
|
<button @click=${() => { this.onCancel?.(); }}>Cancel</button>
|
||||||
|
<button class="primary" ?disabled=${this.path.trim() === ""} @click=${() => { this.submit(); }}>Add project</button>
|
||||||
|
</footer>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
static override styles = css`
|
||||||
|
:host { position: fixed; inset: 0; z-index: 30; color: #e6edf3; font: 14px system-ui, sans-serif; }
|
||||||
|
.backdrop { display: grid; place-items: start center; width: 100%; height: 100%; padding-top: min(12vh, 90px); box-sizing: border-box; background: #0008; }
|
||||||
|
section { width: min(720px, calc(100vw - 40px)); max-height: min(700px, calc(100vh - 40px)); display: flex; flex-direction: column; border: 1px solid #30363d; border-radius: 12px; background: #0d1117; box-shadow: 0 20px 60px #000b; overflow: hidden; }
|
||||||
|
header, footer { display: flex; align-items: center; justify-content: space-between; gap: 8px; padding: 12px; border-bottom: 1px solid #30363d; }
|
||||||
|
footer { border-top: 1px solid #30363d; border-bottom: 0; justify-content: end; }
|
||||||
|
.body { display: grid; gap: 12px; padding: 12px; min-height: 0; }
|
||||||
|
label { display: grid; gap: 6px; color: #8b949e; }
|
||||||
|
input[type="text"], input:not([type]) { box-sizing: border-box; width: 100%; border: 1px solid #30363d; border-radius: 8px; background: #0d1117; color: #e6edf3; padding: 9px; font: 14px ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; }
|
||||||
|
.check { display: flex; grid-template-columns: auto 1fr; align-items: center; color: #e6edf3; }
|
||||||
|
.suggestions { min-height: 90px; max-height: 320px; overflow: auto; border: 1px solid #30363d; border-radius: 8px; background: #161b22; }
|
||||||
|
.suggestions button { display: block; width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; border: 0; border-bottom: 1px solid #30363d; border-radius: 0; background: transparent; color: #e6edf3; padding: 8px 10px; text-align: left; font: 13px ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; }
|
||||||
|
.suggestions button.selected, .suggestions button:hover { background: #0d2847; }
|
||||||
|
.hint { padding: 12px; color: #8b949e; }
|
||||||
|
button { border: 1px solid #30363d; border-radius: 8px; background: #161b22; color: #e6edf3; padding: 7px 9px; cursor: pointer; }
|
||||||
|
header button { border: 0; background: transparent; color: #8b949e; font-size: 22px; padding: 0 8px; }
|
||||||
|
.primary { border-color: #238636; background: #238636; }
|
||||||
|
button:disabled { opacity: .5; cursor: not-allowed; }
|
||||||
|
`;
|
||||||
|
}
|
||||||
@@ -14,13 +14,12 @@ export class ProjectController {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async addProject() {
|
async addProject(path: string, create?: boolean) {
|
||||||
const path = prompt("Project folder path");
|
if (path.trim() === "") return;
|
||||||
if (path === null || path === "") return;
|
|
||||||
try {
|
try {
|
||||||
const project = await api.addProject(path);
|
const project = await api.addProject(path.trim(), undefined, create);
|
||||||
const projects = this.getState().projects;
|
const projects = this.getState().projects;
|
||||||
this.setState({ projects: [...projects.filter((p) => p.id !== project.id), project] });
|
this.setState({ projects: [...projects.filter((p) => p.id !== project.id), project], projectDialogOpen: false });
|
||||||
await this.workspaces.selectProject(project);
|
await this.workspaces.selectProject(project);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.setState({ error: String(error) });
|
this.setState({ error: String(error) });
|
||||||
|
|||||||
+10
-1
@@ -7,6 +7,7 @@ import { ProjectStore } from "./storage/projectStore.js";
|
|||||||
import { ProjectService } from "./projects/projectService.js";
|
import { ProjectService } from "./projects/projectService.js";
|
||||||
import { WorkspaceService } from "./workspaces/workspaceService.js";
|
import { WorkspaceService } from "./workspaces/workspaceService.js";
|
||||||
import { listFileSuggestions, listPathSuggestions } from "./workspaces/fileSuggestions.js";
|
import { listFileSuggestions, listPathSuggestions } from "./workspaces/fileSuggestions.js";
|
||||||
|
import { listDirectorySuggestions } from "./projects/directorySuggestions.js";
|
||||||
import { registerSessionProxyRoutes } from "./sessiond/sessionProxyRoutes.js";
|
import { registerSessionProxyRoutes } from "./sessiond/sessionProxyRoutes.js";
|
||||||
import { registerWorkspaceExplorerRoutes } from "./workspaceExplorerRoutes.js";
|
import { registerWorkspaceExplorerRoutes } from "./workspaceExplorerRoutes.js";
|
||||||
import { registerGitRoutes } from "./gitRoutes.js";
|
import { registerGitRoutes } from "./gitRoutes.js";
|
||||||
@@ -19,7 +20,7 @@ const workspaces = new WorkspaceService();
|
|||||||
|
|
||||||
app.get("/api/projects", async () => projects.list());
|
app.get("/api/projects", async () => projects.list());
|
||||||
|
|
||||||
app.post<{ Body: { name?: string; path: string } }>("/api/projects", async (request, reply) => {
|
app.post<{ Body: { name?: string; path: string; create?: boolean } }>("/api/projects", async (request, reply) => {
|
||||||
try {
|
try {
|
||||||
return await projects.add(request.body);
|
return await projects.add(request.body);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -27,6 +28,14 @@ app.post<{ Body: { name?: string; path: string } }>("/api/projects", async (requ
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
app.get<{ Querystring: { q?: string } }>("/api/project-directories", async (request, reply) => {
|
||||||
|
try {
|
||||||
|
return await listDirectorySuggestions(request.query.q ?? "");
|
||||||
|
} catch (error) {
|
||||||
|
return reply.code(400).send({ error: error instanceof Error ? error.message : String(error) });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
app.get<{ Params: { projectId: string } }>("/api/projects/:projectId/workspaces", async (request, reply) => {
|
app.get<{ Params: { projectId: string } }>("/api/projects/:projectId/workspaces", async (request, reply) => {
|
||||||
try {
|
try {
|
||||||
const project = await projects.requireProject(request.params.projectId);
|
const project = await projects.requireProject(request.params.projectId);
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import { homedir } from "node:os";
|
||||||
|
import { basename, dirname, isAbsolute, resolve, sep } from "node:path";
|
||||||
|
import { readdir, stat } from "node:fs/promises";
|
||||||
|
import type { ClientFileSuggestion } from "../types.js";
|
||||||
|
|
||||||
|
export function expandUserPath(path: string): string {
|
||||||
|
if (path === "" || path === "~") return homedir();
|
||||||
|
if (path.startsWith(`~${sep}`) || path.startsWith("~/")) return resolve(homedir(), path.slice(2));
|
||||||
|
return isAbsolute(path) ? resolve(path) : resolve(process.cwd(), path);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listDirectorySuggestions(query = ""): Promise<ClientFileSuggestion[]> {
|
||||||
|
const raw = query.trim();
|
||||||
|
const expanded = expandUserPath(raw);
|
||||||
|
const endsWithSeparator = raw === "" || raw.endsWith("/") || raw.endsWith("\\") || raw === "~";
|
||||||
|
const parent = endsWithSeparator ? expanded : dirname(expanded);
|
||||||
|
const search = endsWithSeparator ? "" : basename(expanded).toLowerCase();
|
||||||
|
const entries = await readdir(parent, { withFileTypes: true });
|
||||||
|
const suggestions: ClientFileSuggestion[] = [];
|
||||||
|
|
||||||
|
for (const entry of entries) {
|
||||||
|
if (!entry.name.toLowerCase().startsWith(search)) continue;
|
||||||
|
let isDirectory = entry.isDirectory();
|
||||||
|
const path = resolve(parent, entry.name);
|
||||||
|
if (!isDirectory && entry.isSymbolicLink()) {
|
||||||
|
try {
|
||||||
|
isDirectory = (await stat(path)).isDirectory();
|
||||||
|
} catch {
|
||||||
|
isDirectory = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (isDirectory) suggestions.push({ path: `${path}/`, kind: "other" });
|
||||||
|
}
|
||||||
|
|
||||||
|
return suggestions.sort((a, b) => a.path.localeCompare(b.path)).slice(0, 80);
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import { realpath, stat } from "node:fs/promises";
|
import { mkdir, realpath, stat } from "node:fs/promises";
|
||||||
import type { ProjectStore } from "../storage/projectStore.js";
|
import type { ProjectStore } from "../storage/projectStore.js";
|
||||||
import type { Project } from "../types.js";
|
import type { Project } from "../types.js";
|
||||||
|
import { expandUserPath } from "./directorySuggestions.js";
|
||||||
|
|
||||||
export class ProjectService {
|
export class ProjectService {
|
||||||
constructor(private readonly store: ProjectStore) {}
|
constructor(private readonly store: ProjectStore) {}
|
||||||
@@ -9,8 +10,10 @@ export class ProjectService {
|
|||||||
return this.store.list();
|
return this.store.list();
|
||||||
}
|
}
|
||||||
|
|
||||||
async add(input: { name?: string; path: string }): Promise<Project> {
|
async add(input: { name?: string; path: string; create?: boolean }): Promise<Project> {
|
||||||
const resolved = await realpath(input.path);
|
const requestedPath = expandUserPath(input.path);
|
||||||
|
if (input.create === true) await mkdir(requestedPath, { recursive: true });
|
||||||
|
const resolved = await realpath(requestedPath);
|
||||||
const s = await stat(resolved);
|
const s = await stat(resolved);
|
||||||
if (!s.isDirectory()) throw new Error("Project path must be a directory");
|
if (!s.isDirectory()) throw new Error("Project path must be a directory");
|
||||||
return this.store.add(input.name === undefined ? { path: resolved } : { name: input.name, path: resolved });
|
return this.store.add(input.name === undefined ? { path: resolved } : { name: input.name, path: resolved });
|
||||||
|
|||||||
Reference in New Issue
Block a user