feat: add ability to upload external plans and remove them from sidebar
This commit is contained in:
@@ -11,7 +11,9 @@ import {
|
|||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { Textarea } from '@/components/ui/textarea';
|
import { Textarea } from '@/components/ui/textarea';
|
||||||
import {
|
import {
|
||||||
|
deleteProjectPlanFile,
|
||||||
getProjectContextData,
|
getProjectContextData,
|
||||||
|
importProjectPlanFileFromContent,
|
||||||
OPENCHAMBER_PROJECT_NOTES_MAX_LENGTH,
|
OPENCHAMBER_PROJECT_NOTES_MAX_LENGTH,
|
||||||
readProjectPlanFile,
|
readProjectPlanFile,
|
||||||
OPENCHAMBER_PROJECT_TODO_TEXT_MAX_LENGTH,
|
OPENCHAMBER_PROJECT_TODO_TEXT_MAX_LENGTH,
|
||||||
@@ -405,6 +407,72 @@ export const ProjectNotesTodoPanel: React.FC<ProjectNotesTodoPanelProps> = ({
|
|||||||
[canCreateWorktree, createSession, initializeNewOpenChamberSession, onActionComplete, pendingSendTarget, projectRef, routeToChat, sendMessage, setCurrentSession]
|
[canCreateWorktree, createSession, initializeNewOpenChamberSession, onActionComplete, pendingSendTarget, projectRef, routeToChat, sendMessage, setCurrentSession]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const planFileInputRef = React.useRef<HTMLInputElement | null>(null);
|
||||||
|
const [isImportingPlan, setIsImportingPlan] = React.useState(false);
|
||||||
|
const [deletingPlanId, setDeletingPlanId] = React.useState<string | null>(null);
|
||||||
|
|
||||||
|
const handleDeletePlan = React.useCallback(
|
||||||
|
async (planId: string) => {
|
||||||
|
if (!projectRef || deletingPlanId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setDeletingPlanId(planId);
|
||||||
|
try {
|
||||||
|
const ok = await deleteProjectPlanFile(projectRef, planId);
|
||||||
|
if (!ok) {
|
||||||
|
toast.error('Failed to delete plan');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setPlans((previous) => previous.filter((entry) => entry.id !== planId));
|
||||||
|
window.dispatchEvent(new CustomEvent('openchamber:project-plan-saved', {
|
||||||
|
detail: { projectId: projectRef.id },
|
||||||
|
}));
|
||||||
|
} finally {
|
||||||
|
setDeletingPlanId(null);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[deletingPlanId, projectRef]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleTriggerUploadPlan = React.useCallback(() => {
|
||||||
|
if (!projectRef || isImportingPlan) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
planFileInputRef.current?.click();
|
||||||
|
}, [isImportingPlan, projectRef]);
|
||||||
|
|
||||||
|
const handleUploadPlanFile = React.useCallback(
|
||||||
|
async (file: File | null) => {
|
||||||
|
if (!projectRef || !file) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setIsImportingPlan(true);
|
||||||
|
try {
|
||||||
|
const text = await file.text();
|
||||||
|
if (!text.trim()) {
|
||||||
|
toast.error('Plan file is empty');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const fallbackTitle = file.name.replace(/\.(md|markdown|txt)$/i, '').trim();
|
||||||
|
const created = await importProjectPlanFileFromContent(projectRef, text, fallbackTitle);
|
||||||
|
if (!created) {
|
||||||
|
toast.error('Failed to import plan');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
window.dispatchEvent(new CustomEvent('openchamber:project-plan-saved', {
|
||||||
|
detail: { projectId: projectRef.id },
|
||||||
|
}));
|
||||||
|
toast.success('Plan imported');
|
||||||
|
} catch (error) {
|
||||||
|
const description = error instanceof Error ? error.message : undefined;
|
||||||
|
toast.error('Failed to read plan file', description ? { description } : undefined);
|
||||||
|
} finally {
|
||||||
|
setIsImportingPlan(false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[projectRef]
|
||||||
|
);
|
||||||
|
|
||||||
const handleOpenPlan = React.useCallback(
|
const handleOpenPlan = React.useCallback(
|
||||||
(plan: ProjectPlanListItem) => {
|
(plan: ProjectPlanListItem) => {
|
||||||
const projectPath = projectRef?.path?.trim();
|
const projectPath = projectRef?.path?.trim();
|
||||||
@@ -572,6 +640,27 @@ export const ProjectNotesTodoPanel: React.FC<ProjectNotesTodoPanelProps> = ({
|
|||||||
<h3 className="typography-ui-label font-semibold text-foreground">Plans</h3>
|
<h3 className="typography-ui-label font-semibold text-foreground">Plans</h3>
|
||||||
<span className="typography-meta text-muted-foreground">{plans.length} file{plans.length === 1 ? '' : 's'}</span>
|
<span className="typography-meta text-muted-foreground">{plans.length} file{plans.length === 1 ? '' : 's'}</span>
|
||||||
</div>
|
</div>
|
||||||
|
<input
|
||||||
|
ref={planFileInputRef}
|
||||||
|
type="file"
|
||||||
|
accept=".md,.markdown,.txt,text/markdown,text/plain"
|
||||||
|
className="hidden"
|
||||||
|
onChange={(event) => {
|
||||||
|
const file = event.target.files?.[0] ?? null;
|
||||||
|
void handleUploadPlanFile(file);
|
||||||
|
event.currentTarget.value = '';
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleTriggerUploadPlan}
|
||||||
|
disabled={!projectRef || isImportingPlan}
|
||||||
|
className="inline-flex h-6 w-6 items-center justify-center rounded-md border border-border/70 text-muted-foreground hover:text-foreground hover:bg-interactive-hover/50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50 disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
|
aria-label="Import plan from file"
|
||||||
|
title="Import plan from file"
|
||||||
|
>
|
||||||
|
<RiAddLine className="h-3.5 w-3.5" />
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="max-h-56 overflow-y-auto rounded-lg border border-border/60 bg-background/40">
|
<div className="max-h-56 overflow-y-auto rounded-lg border border-border/60 bg-background/40">
|
||||||
@@ -580,17 +669,27 @@ export const ProjectNotesTodoPanel: React.FC<ProjectNotesTodoPanelProps> = ({
|
|||||||
) : (
|
) : (
|
||||||
<ul className="divide-y divide-border/50">
|
<ul className="divide-y divide-border/50">
|
||||||
{plans.map((plan) => (
|
{plans.map((plan) => (
|
||||||
<li key={plan.id} className="px-2.5 py-1.5">
|
<li key={plan.id} className="flex items-center gap-1.5 px-2.5 py-1.5">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => handleOpenPlan(plan)}
|
onClick={() => handleOpenPlan(plan)}
|
||||||
className="flex w-full min-w-0 items-center justify-between gap-3 rounded-md px-1.5 py-1 text-left hover:bg-interactive-hover/50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50"
|
className="flex min-w-0 flex-1 items-center justify-between gap-3 rounded-md px-1.5 py-1 text-left hover:bg-interactive-hover/50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50"
|
||||||
>
|
>
|
||||||
<span className="min-w-0 truncate typography-ui-label text-foreground">{plan.title}</span>
|
<span className="min-w-0 truncate typography-ui-label text-foreground">{plan.title}</span>
|
||||||
<span className="flex-shrink-0 typography-micro text-muted-foreground">
|
<span className="flex-shrink-0 typography-micro text-muted-foreground">
|
||||||
{new Date(plan.createdAt).toLocaleDateString()}
|
{new Date(plan.createdAt).toLocaleDateString()}
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => void handleDeletePlan(plan.id)}
|
||||||
|
disabled={deletingPlanId === plan.id}
|
||||||
|
className="inline-flex h-6 w-6 flex-shrink-0 items-center justify-center rounded-md text-muted-foreground hover:text-foreground hover:bg-interactive-hover/50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50 disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
|
aria-label={`Delete plan "${plan.title}"`}
|
||||||
|
title="Delete plan"
|
||||||
|
>
|
||||||
|
<RiDeleteBinLine className="h-3.5 w-3.5" />
|
||||||
|
</button>
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
|
|||||||
@@ -749,6 +749,64 @@ export async function readProjectPlanFile(path: string): Promise<OpenChamberProj
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const deleteFile = async (path: string): Promise<boolean> => {
|
||||||
|
const runtimeFiles = getRuntimeFilesAPI();
|
||||||
|
if (runtimeFiles?.delete) {
|
||||||
|
try {
|
||||||
|
const result = await runtimeFiles.delete(path);
|
||||||
|
if (result?.success !== false) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// fall through
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const res = await postJson<{ success?: boolean }>(`${getBaseUrl()}/fs/delete`, { path });
|
||||||
|
return Boolean(res.ok);
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function deleteProjectPlanFile(
|
||||||
|
project: ProjectRef,
|
||||||
|
planId: string
|
||||||
|
): Promise<boolean> {
|
||||||
|
const trimmedId = typeof planId === 'string' ? planId.trim() : '';
|
||||||
|
if (!trimmedId) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const existing = await getProjectPlanFiles(project);
|
||||||
|
const target = existing.find((entry) => entry.id === trimmedId);
|
||||||
|
if (!target) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const next = existing.filter((entry) => entry.id !== trimmedId);
|
||||||
|
const saved = await saveProjectPlanFiles(project, next);
|
||||||
|
if (!saved) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Best-effort: remove underlying markdown file, ignore failure.
|
||||||
|
await deleteFile(target.path).catch(() => false);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function importProjectPlanFileFromContent(
|
||||||
|
project: ProjectRef,
|
||||||
|
content: string,
|
||||||
|
fallbackTitle?: string
|
||||||
|
): Promise<OpenChamberProjectPlanFileLink | null> {
|
||||||
|
const raw = typeof content === 'string' ? content : '';
|
||||||
|
if (!raw.trim()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsed = parseProjectPlanMarkdown(raw);
|
||||||
|
const title = parsed.title || sanitizePlanTitle(fallbackTitle ?? '') || 'Plan';
|
||||||
|
return createProjectPlanFile(project, { title, body: parsed.body });
|
||||||
|
}
|
||||||
|
|
||||||
export async function createProjectPlanFile(
|
export async function createProjectPlanFile(
|
||||||
project: ProjectRef,
|
project: ProjectRef,
|
||||||
value: { title: string; body: string }
|
value: { title: string; body: string }
|
||||||
|
|||||||
Reference in New Issue
Block a user