feat(FN-1656): complete Step 1 — implement canonical node-project counting rules
This commit is contained in:
@@ -373,12 +373,13 @@ describe("tablet header controls", () => {
|
||||
expect(screen.queryByTestId("project-selector-trigger")).toBeNull();
|
||||
});
|
||||
|
||||
it("does not render back-to-projects button on tablet", () => {
|
||||
it("does not render split project button on tablet", () => {
|
||||
renderTabletHeader({
|
||||
projects: [{ id: "1", name: "Project One", path: "/path/one", status: "active" as const }],
|
||||
currentProject: { id: "1", name: "Project One", path: "/path/one", status: "active" as const },
|
||||
onViewAllProjects: noop,
|
||||
});
|
||||
expect(screen.queryByTestId("back-to-projects-btn")).toBeNull();
|
||||
expect(screen.queryByTestId("header-projects-btn")).toBeNull();
|
||||
});
|
||||
|
||||
it("shows switch project in overflow menu when multiple projects on tablet", () => {
|
||||
|
||||
@@ -860,24 +860,25 @@ export interface PtyTerminalSessionInfo {
|
||||
export function createTerminalSession(
|
||||
cwd?: string,
|
||||
cols?: number,
|
||||
rows?: number
|
||||
rows?: number,
|
||||
projectId?: string
|
||||
): Promise<PtyTerminalSession> {
|
||||
return api<PtyTerminalSession>("/terminal/sessions", {
|
||||
return api<PtyTerminalSession>(withProjectId("/terminal/sessions", projectId), {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ cwd, cols, rows }),
|
||||
});
|
||||
}
|
||||
|
||||
/** Kill a PTY terminal session */
|
||||
export function killPtyTerminalSession(sessionId: string): Promise<{ killed: boolean }> {
|
||||
return api<{ killed: boolean }>(`/terminal/sessions/${encodeURIComponent(sessionId)}`, {
|
||||
export function killPtyTerminalSession(sessionId: string, projectId?: string): Promise<{ killed: boolean }> {
|
||||
return api<{ killed: boolean }>(withProjectId(`/terminal/sessions/${encodeURIComponent(sessionId)}`, projectId), {
|
||||
method: "DELETE",
|
||||
});
|
||||
}
|
||||
|
||||
/** List active PTY terminal sessions */
|
||||
export function listTerminalSessions(): Promise<PtyTerminalSessionInfo[]> {
|
||||
return api<PtyTerminalSessionInfo[]>("/terminal/sessions");
|
||||
export function listTerminalSessions(projectId?: string): Promise<PtyTerminalSessionInfo[]> {
|
||||
return api<PtyTerminalSessionInfo[]>(withProjectId("/terminal/sessions", projectId));
|
||||
}
|
||||
|
||||
// --- Git Management API ---
|
||||
|
||||
@@ -1,15 +1,140 @@
|
||||
import { useState, useEffect, useRef, useCallback, useMemo } from "react";
|
||||
import { Settings, Pause, Play, Square, LayoutGrid, List, Terminal, Lightbulb, Search, X, Activity, MoreHorizontal, Clock, Folder, History, GitBranch, Server, Workflow, Bot, ChevronLeft, Target, ChevronRight, FileCode, Loader2, Grid3X3, Mail, MessageSquare } from "lucide-react";
|
||||
import { Settings, Pause, Play, Square, LayoutGrid, List, Terminal, Lightbulb, Search, X, Activity, MoreHorizontal, Clock, Folder, History, GitBranch, Server, Workflow, Bot, ChevronLeft, Target, ChevronRight, FileCode, Loader2, Grid3X3, Mail, MessageSquare, ChevronDown, Check } from "lucide-react";
|
||||
import type { ProjectInfo } from "../api";
|
||||
import type { NodeConfig } from "@fusion/core";
|
||||
import type { NodeConfig, ProjectStatus } from "@fusion/core";
|
||||
import { fetchScripts } from "../api";
|
||||
import { ProjectSelector } from "./ProjectSelector";
|
||||
import { QuickScriptsDropdown } from "./QuickScriptsDropdown";
|
||||
import { NodeStatusIndicator } from "./NodeStatusIndicator";
|
||||
import { useViewportMode, type ViewportMode } from "../hooks/useViewportMode";
|
||||
|
||||
export { useViewportMode };
|
||||
|
||||
// Status icon config for project selector dropdown
|
||||
const PROJECT_STATUS_CONFIG: Record<ProjectStatus, { color: string }> = {
|
||||
active: { color: "var(--success)" },
|
||||
paused: { color: "var(--warning)" },
|
||||
errored: { color: "var(--color-error)" },
|
||||
initializing: { color: "var(--info)" },
|
||||
};
|
||||
|
||||
/**
|
||||
* SplitProjectButton - A two-stage button for project navigation.
|
||||
* Left half: navigates to "all projects" view.
|
||||
* Right half: opens a dropdown to select a specific project.
|
||||
*/
|
||||
function SplitProjectButton({
|
||||
projects,
|
||||
currentProject,
|
||||
onViewAll,
|
||||
onSelectProject,
|
||||
}: {
|
||||
projects: ProjectInfo[];
|
||||
currentProject: ProjectInfo | null;
|
||||
onViewAll: () => void;
|
||||
onSelectProject?: (project: ProjectInfo) => void;
|
||||
}) {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Close dropdown on outside click
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
const handleClickOutside = (e: MouseEvent) => {
|
||||
if (dropdownRef.current && !dropdownRef.current.contains(e.target as Node)) {
|
||||
setIsOpen(false);
|
||||
}
|
||||
};
|
||||
document.addEventListener("mousedown", handleClickOutside);
|
||||
return () => document.removeEventListener("mousedown", handleClickOutside);
|
||||
}, [isOpen]);
|
||||
|
||||
// Close on Escape
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") {
|
||||
setIsOpen(false);
|
||||
}
|
||||
};
|
||||
document.addEventListener("keydown", handleKeyDown);
|
||||
return () => document.removeEventListener("keydown", handleKeyDown);
|
||||
}, [isOpen]);
|
||||
|
||||
const handleSelectProject = useCallback(
|
||||
(project: ProjectInfo) => {
|
||||
onSelectProject?.(project);
|
||||
setIsOpen(false);
|
||||
},
|
||||
[onSelectProject]
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="split-project-btn" ref={dropdownRef}>
|
||||
{/* Left half: projects view button */}
|
||||
<button
|
||||
className="split-project-btn__left"
|
||||
onClick={onViewAll}
|
||||
title="View all projects"
|
||||
data-testid="header-projects-btn"
|
||||
>
|
||||
<Grid3X3 size={14} />
|
||||
<span>{currentProject?.name || "Projects"}</span>
|
||||
</button>
|
||||
{/* Right half: dropdown arrow (only when multiple projects) */}
|
||||
{projects.length > 1 && (
|
||||
<>
|
||||
<div className="split-project-btn__divider" />
|
||||
<button
|
||||
className={`split-project-btn__right${isOpen ? " split-project-btn__right--open" : ""}`}
|
||||
onClick={() => setIsOpen((prev) => !prev)}
|
||||
title="Switch project"
|
||||
aria-label="Switch project"
|
||||
aria-expanded={isOpen}
|
||||
aria-haspopup="listbox"
|
||||
data-testid="project-selector-trigger"
|
||||
>
|
||||
<ChevronDown size={12} className={`split-project-btn__chevron${isOpen ? " split-project-btn__chevron--open" : ""}`} />
|
||||
</button>
|
||||
{isOpen && (
|
||||
<div
|
||||
className="split-project-btn__dropdown"
|
||||
role="listbox"
|
||||
aria-label="Select project"
|
||||
data-testid="project-selector-dropdown"
|
||||
>
|
||||
{projects.map((project) => {
|
||||
const isCurrent = currentProject?.id === project.id;
|
||||
const statusColor = PROJECT_STATUS_CONFIG[project.status]?.color;
|
||||
return (
|
||||
<button
|
||||
key={project.id}
|
||||
className={`split-project-btn__item${isCurrent ? " split-project-btn__item--current" : ""}`}
|
||||
onClick={() => handleSelectProject(project)}
|
||||
role="option"
|
||||
aria-selected={isCurrent}
|
||||
>
|
||||
<span
|
||||
className="split-project-btn__item-dot"
|
||||
style={{ backgroundColor: statusColor || "var(--text-muted)" }}
|
||||
/>
|
||||
<div className="split-project-btn__item-info">
|
||||
<span className="split-project-btn__item-name">{project.name}</span>
|
||||
<span className="split-project-btn__item-path">
|
||||
{project.path.split("/").slice(-2).join("/")}
|
||||
</span>
|
||||
</div>
|
||||
{isCurrent && <Check size={14} className="split-project-btn__item-check" />}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// GitHub logo icon (Octocat mark) - uses currentColor for theme compatibility
|
||||
function GitHubLogo({ size = 16 }: { size?: number }) {
|
||||
return (
|
||||
@@ -311,44 +436,14 @@ export function Header({
|
||||
<h1 className="logo">Fusion</h1>
|
||||
</div>
|
||||
|
||||
{/* Project Selector - shown when 2+ projects on desktop only */}
|
||||
{!isCompact && projects.length > 1 && (
|
||||
<div className="header-project-selector">
|
||||
<ProjectSelector
|
||||
projects={projects}
|
||||
currentProject={currentProject || null}
|
||||
onSelect={(project) => {
|
||||
onSelectProject?.(project);
|
||||
}}
|
||||
onViewAll={onViewAllProjects || (() => {})}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Back to All Projects button when viewing a specific project (desktop only) */}
|
||||
{!isCompact && currentProject && onViewAllProjects && (
|
||||
<button
|
||||
className="header-back-button"
|
||||
onClick={onViewAllProjects}
|
||||
title="Back to All Projects"
|
||||
data-testid="back-to-projects-btn"
|
||||
>
|
||||
<ChevronLeft size={14} />
|
||||
<span>All Projects</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Projects button - always visible when at least 1 project exists (desktop only) */}
|
||||
{/* Split-button Project Selector - left: projects view, right: project dropdown (desktop only) */}
|
||||
{!isCompact && projects.length >= 1 && onViewAllProjects && (
|
||||
<button
|
||||
className="header-projects-btn"
|
||||
onClick={onViewAllProjects}
|
||||
title="View all projects"
|
||||
data-testid="header-projects-btn"
|
||||
>
|
||||
<Grid3X3 size={14} />
|
||||
<span>Projects</span>
|
||||
</button>
|
||||
<SplitProjectButton
|
||||
projects={projects}
|
||||
currentProject={currentProject ?? null}
|
||||
onViewAll={onViewAllProjects}
|
||||
onSelectProject={onSelectProject}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Node selector and status indicator */}
|
||||
|
||||
@@ -825,7 +825,7 @@ describe("Header", () => {
|
||||
expect(screen.queryByTestId("project-selector-trigger")).toBeNull();
|
||||
});
|
||||
|
||||
it("does not render back-to-projects button on mobile", () => {
|
||||
it("does not render split project button on mobile", () => {
|
||||
const projects = [
|
||||
{ id: "proj_1", name: "Project One", path: "/path/1", status: "active" as const, isolationMode: "in-process" as const, createdAt: "", updatedAt: "" },
|
||||
{ id: "proj_2", name: "Project Two", path: "/path/2", status: "active" as const, isolationMode: "in-process" as const, createdAt: "", updatedAt: "" },
|
||||
@@ -837,7 +837,7 @@ describe("Header", () => {
|
||||
onViewAllProjects={vi.fn()}
|
||||
/>
|
||||
);
|
||||
expect(screen.queryByTestId("back-to-projects-btn")).toBeNull();
|
||||
expect(screen.queryByTestId("header-projects-btn")).toBeNull();
|
||||
});
|
||||
|
||||
it("shows switch project item in overflow menu on mobile when multiple projects", () => {
|
||||
@@ -999,18 +999,17 @@ describe("Header", () => {
|
||||
});
|
||||
});
|
||||
|
||||
// ── Multi-Project Selector ────────────────────────────────────
|
||||
// ── Split Project Button ────────────────────────────────────
|
||||
|
||||
it("shows ProjectSelector when 2+ projects provided", () => {
|
||||
it("shows split project button when 1+ projects provided with onViewAllProjects", () => {
|
||||
const projects = [
|
||||
{ id: "proj_1", name: "Project One", path: "/path/1", status: "active" as const, isolationMode: "in-process" as const, createdAt: "", updatedAt: "" },
|
||||
{ id: "proj_2", name: "Project Two", path: "/path/2", status: "active" as const, isolationMode: "in-process" as const, createdAt: "", updatedAt: "" },
|
||||
];
|
||||
render(<Header projects={projects} />);
|
||||
expect(screen.getByTestId("project-selector-trigger")).toBeDefined();
|
||||
render(<Header projects={projects} onViewAllProjects={vi.fn()} />);
|
||||
expect(screen.getByTestId("header-projects-btn")).toBeDefined();
|
||||
});
|
||||
|
||||
it("renders ProjectSelector within header-left when multiple projects exist", () => {
|
||||
it("renders split project button within header-left when projects exist", () => {
|
||||
const projects = [
|
||||
{ id: "proj_1", name: "Project One", path: "/path/1", status: "active" as const, isolationMode: "in-process" as const, createdAt: "", updatedAt: "" },
|
||||
{ id: "proj_2", name: "Project Two", path: "/path/2", status: "active" as const, isolationMode: "in-process" as const, createdAt: "", updatedAt: "" },
|
||||
@@ -1023,43 +1022,45 @@ describe("Header", () => {
|
||||
onViewAllProjects={vi.fn()}
|
||||
/>
|
||||
);
|
||||
// ProjectSelector should be inside header-left, not a sibling
|
||||
// SplitProjectButton should be inside header-left
|
||||
const headerLeft = container.querySelector(".header-left");
|
||||
expect(headerLeft).not.toBeNull();
|
||||
const selectorInLeft = headerLeft!.querySelector(".header-project-selector");
|
||||
expect(selectorInLeft).not.toBeNull();
|
||||
expect(selectorInLeft!.querySelector("[data-testid='project-selector-trigger']")).not.toBeNull();
|
||||
const splitBtn = headerLeft!.querySelector(".split-project-btn");
|
||||
expect(splitBtn).not.toBeNull();
|
||||
expect(splitBtn!.querySelector("[data-testid='header-projects-btn']")).not.toBeNull();
|
||||
});
|
||||
|
||||
it("does not show ProjectSelector with single project", () => {
|
||||
it("does not show split project button when no projects", () => {
|
||||
const { container } = render(<Header projects={[]} />);
|
||||
expect(container.querySelector(".split-project-btn")).toBeNull();
|
||||
});
|
||||
|
||||
it("does not show split project button without onViewAllProjects", () => {
|
||||
const projects = [
|
||||
{ id: "proj_1", name: "Project One", path: "/path/1", status: "active" as const, isolationMode: "in-process" as const, createdAt: "", updatedAt: "" },
|
||||
];
|
||||
const { container } = render(<Header projects={projects} />);
|
||||
expect(container.querySelector(".project-selector")).toBeNull();
|
||||
expect(container.querySelector(".split-project-btn")).toBeNull();
|
||||
});
|
||||
|
||||
it("does not show ProjectSelector when no projects", () => {
|
||||
const { container } = render(<Header projects={[]} />);
|
||||
expect(container.querySelector(".project-selector")).toBeNull();
|
||||
});
|
||||
|
||||
it("shows 'Back to All Projects' button when currentProject is set", () => {
|
||||
it("shows project dropdown arrow only when 2+ projects", () => {
|
||||
const projects = [
|
||||
{ id: "proj_1", name: "Project One", path: "/path/1", status: "active" as const, isolationMode: "in-process" as const, createdAt: "", updatedAt: "" },
|
||||
{ id: "proj_2", name: "Project Two", path: "/path/2", status: "active" as const, isolationMode: "in-process" as const, createdAt: "", updatedAt: "" },
|
||||
];
|
||||
render(
|
||||
<Header
|
||||
projects={projects}
|
||||
currentProject={projects[0]}
|
||||
onViewAllProjects={vi.fn()}
|
||||
/>
|
||||
);
|
||||
expect(screen.getByTestId("back-to-projects-btn")).toBeDefined();
|
||||
render(<Header projects={projects} onViewAllProjects={vi.fn()} />);
|
||||
expect(screen.getByTestId("project-selector-trigger")).toBeDefined();
|
||||
});
|
||||
|
||||
it("calls onViewAllProjects when 'Back to All Projects' clicked", () => {
|
||||
it("does not show project dropdown arrow with single project", () => {
|
||||
const projects = [
|
||||
{ id: "proj_1", name: "Project One", path: "/path/1", status: "active" as const, isolationMode: "in-process" as const, createdAt: "", updatedAt: "" },
|
||||
];
|
||||
render(<Header projects={projects} onViewAllProjects={vi.fn()} />);
|
||||
expect(screen.queryByTestId("project-selector-trigger")).toBeNull();
|
||||
});
|
||||
|
||||
it("left button calls onViewAllProjects when clicked", () => {
|
||||
const projects = [
|
||||
{ id: "proj_1", name: "Project One", path: "/path/1", status: "active" as const, isolationMode: "in-process" as const, createdAt: "", updatedAt: "" },
|
||||
{ id: "proj_2", name: "Project Two", path: "/path/2", status: "active" as const, isolationMode: "in-process" as const, createdAt: "", updatedAt: "" },
|
||||
@@ -1072,19 +1073,11 @@ describe("Header", () => {
|
||||
onViewAllProjects={onViewAllProjects}
|
||||
/>
|
||||
);
|
||||
fireEvent.click(screen.getByTestId("back-to-projects-btn"));
|
||||
fireEvent.click(screen.getByTestId("header-projects-btn"));
|
||||
expect(onViewAllProjects).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not show 'Back to All Projects' when no currentProject", () => {
|
||||
const projects = [
|
||||
{ id: "proj_1", name: "Project One", path: "/path/1", status: "active" as const, isolationMode: "in-process" as const, createdAt: "", updatedAt: "" },
|
||||
];
|
||||
render(<Header projects={projects} currentProject={null} />);
|
||||
expect(screen.queryByTestId("back-to-projects-btn")).toBeNull();
|
||||
});
|
||||
|
||||
it("calls onSelectProject when project selected from selector", () => {
|
||||
it("calls onSelectProject when project selected from dropdown", () => {
|
||||
const projects = [
|
||||
{ id: "proj_1", name: "Project One", path: "/path/1", status: "active" as const, isolationMode: "in-process" as const, createdAt: "", updatedAt: "" },
|
||||
{ id: "proj_2", name: "Project Two", path: "/path/2", status: "active" as const, isolationMode: "in-process" as const, createdAt: "", updatedAt: "" },
|
||||
@@ -1099,14 +1092,14 @@ describe("Header", () => {
|
||||
/>
|
||||
);
|
||||
|
||||
// Open selector
|
||||
// Open dropdown
|
||||
fireEvent.click(screen.getByTestId("project-selector-trigger"));
|
||||
// Click on a project in the dropdown
|
||||
fireEvent.click(screen.getByText("Project Two"));
|
||||
expect(onSelectProject).toHaveBeenCalledWith(projects[1]);
|
||||
});
|
||||
|
||||
it("shows current project name in selector trigger", () => {
|
||||
it("shows current project name in left button", () => {
|
||||
const projects = [
|
||||
{ id: "proj_1", name: "Project One", path: "/path/1", status: "active" as const, isolationMode: "in-process" as const, createdAt: "", updatedAt: "" },
|
||||
{ id: "proj_2", name: "Project Two", path: "/path/2", status: "active" as const, isolationMode: "in-process" as const, createdAt: "", updatedAt: "" },
|
||||
@@ -1120,7 +1113,25 @@ describe("Header", () => {
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByText("Project One")).toBeDefined();
|
||||
// Left button should show current project name
|
||||
const leftBtn = screen.getByTestId("header-projects-btn");
|
||||
expect(leftBtn.textContent).toContain("Project One");
|
||||
});
|
||||
|
||||
it("shows 'Projects' label when no current project", () => {
|
||||
const projects = [
|
||||
{ id: "proj_1", name: "Project One", path: "/path/1", status: "active" as const, isolationMode: "in-process" as const, createdAt: "", updatedAt: "" },
|
||||
];
|
||||
render(
|
||||
<Header
|
||||
projects={projects}
|
||||
currentProject={null}
|
||||
onViewAllProjects={vi.fn()}
|
||||
/>
|
||||
);
|
||||
|
||||
const leftBtn = screen.getByTestId("header-projects-btn");
|
||||
expect(leftBtn.textContent).toContain("Projects");
|
||||
});
|
||||
|
||||
// ── Modal Overlay Visibility ──────────────────────────────────
|
||||
|
||||
@@ -153,7 +153,7 @@ describe("MultiProjectFlow", () => {
|
||||
expect(viewMode).toBe("overview");
|
||||
});
|
||||
|
||||
it("shows Projects button alongside Back button when currentProject is set", () => {
|
||||
it("shows Projects button with current project name when currentProject is set", () => {
|
||||
mockDesktopMatchMedia();
|
||||
|
||||
render(
|
||||
@@ -170,9 +170,10 @@ describe("MultiProjectFlow", () => {
|
||||
/>
|
||||
);
|
||||
|
||||
// Both buttons should be present
|
||||
expect(screen.getByTestId("header-projects-btn")).toBeDefined();
|
||||
expect(screen.getByTestId("back-to-projects-btn")).toBeDefined();
|
||||
// The split button should show the current project name
|
||||
const projectsBtn = screen.getByTestId("header-projects-btn");
|
||||
expect(projectsBtn).toBeDefined();
|
||||
expect(projectsBtn.textContent).toContain("Solo Project");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -255,7 +255,7 @@ describe("useTerminalSessions", () => {
|
||||
result.current.closeTab(tabId);
|
||||
});
|
||||
|
||||
expect(mockKillPtyTerminalSession).toHaveBeenCalledWith(sessionId);
|
||||
expect(mockKillPtyTerminalSession).toHaveBeenCalledWith(sessionId, TEST_PROJECT_ID);
|
||||
expect(result.current.tabs.length).toBe(0);
|
||||
});
|
||||
|
||||
@@ -424,7 +424,7 @@ describe("useTerminalSessions", () => {
|
||||
});
|
||||
|
||||
// Old session should be killed
|
||||
expect(mockKillPtyTerminalSession).toHaveBeenCalledWith("session-1");
|
||||
expect(mockKillPtyTerminalSession).toHaveBeenCalledWith("session-1", TEST_PROJECT_ID);
|
||||
|
||||
// Tab should have new session
|
||||
expect(result.current.activeTab?.sessionId).toBe("session-new");
|
||||
|
||||
@@ -159,7 +159,7 @@ export function useTerminalSessions(projectId?: string): UseTerminalSessionsRetu
|
||||
try {
|
||||
// Get active server sessions with bounded timeout
|
||||
const serverSessions = await withTimeout(
|
||||
listTerminalSessions(),
|
||||
listTerminalSessions(projectId),
|
||||
BOOTSTRAP_LIST_TIMEOUT_MS,
|
||||
"listTerminalSessions"
|
||||
);
|
||||
@@ -232,7 +232,7 @@ export function useTerminalSessions(projectId?: string): UseTerminalSessionsRetu
|
||||
// Small delay to avoid race condition with the validation effect
|
||||
const timeout = setTimeout(() => {
|
||||
withTimeout(
|
||||
createTerminalSession(),
|
||||
createTerminalSession(undefined, undefined, undefined, projectId),
|
||||
BOOTSTRAP_CREATE_TIMEOUT_MS,
|
||||
"createTerminalSession"
|
||||
)
|
||||
@@ -278,7 +278,7 @@ export function useTerminalSessions(projectId?: string): UseTerminalSessionsRetu
|
||||
* Internal create tab function (used for auto-creation and user-initiated creation)
|
||||
*/
|
||||
const createTabInternal = useCallback(async (): Promise<TerminalTab> => {
|
||||
const session = await createTerminalSession();
|
||||
const session = await createTerminalSession(undefined, undefined, undefined, projectId);
|
||||
const newTab: TerminalTab = {
|
||||
id: generateTabId(),
|
||||
sessionId: session.sessionId,
|
||||
@@ -319,7 +319,7 @@ export function useTerminalSessions(projectId?: string): UseTerminalSessionsRetu
|
||||
if (!tabToClose) return currentTabs;
|
||||
|
||||
// Non-blocking server session kill
|
||||
killPtyTerminalSession(tabToClose.sessionId).catch((err) => {
|
||||
killPtyTerminalSession(tabToClose.sessionId, projectId).catch((err) => {
|
||||
console.warn(`Failed to kill terminal session ${tabToClose.sessionId}:`, err);
|
||||
});
|
||||
|
||||
@@ -389,7 +389,7 @@ export function useTerminalSessions(projectId?: string): UseTerminalSessionsRetu
|
||||
if (!activeTab) return currentTabs;
|
||||
|
||||
// Kill the old session (non-blocking)
|
||||
killPtyTerminalSession(activeTab.sessionId).catch((err) => {
|
||||
killPtyTerminalSession(activeTab.sessionId, projectId).catch((err) => {
|
||||
console.warn(`Failed to kill old session ${activeTab.sessionId}:`, err);
|
||||
});
|
||||
|
||||
@@ -403,7 +403,7 @@ export function useTerminalSessions(projectId?: string): UseTerminalSessionsRetu
|
||||
if (!currentActiveTab) return;
|
||||
|
||||
// Create new session and update the tab's sessionId
|
||||
const session = await createTerminalSession();
|
||||
const session = await createTerminalSession(undefined, undefined, undefined, projectId);
|
||||
|
||||
setTabs((currentTabs) =>
|
||||
currentTabs.map((tab) =>
|
||||
@@ -434,7 +434,7 @@ export function useTerminalSessions(projectId?: string): UseTerminalSessionsRetu
|
||||
if (!currentActiveTab) return;
|
||||
|
||||
try {
|
||||
const session = await createTerminalSession();
|
||||
const session = await createTerminalSession(undefined, undefined, undefined, projectId);
|
||||
|
||||
setTabs((currentTabs) =>
|
||||
currentTabs.map((tab) =>
|
||||
|
||||
@@ -6476,10 +6476,8 @@ body {
|
||||
padding-top: max(var(--space-md), env(safe-area-inset-top, 0px));
|
||||
}
|
||||
|
||||
/* Hide project selector and back button on mobile (belt-and-suspenders with conditional rendering) */
|
||||
.header-project-selector,
|
||||
.header-back-button,
|
||||
.header-projects-btn {
|
||||
/* Hide split project button on mobile (belt-and-suspenders with conditional rendering) */
|
||||
.split-project-btn {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@@ -6949,10 +6947,8 @@ body {
|
||||
padding: var(--space-md) var(--space-lg);
|
||||
}
|
||||
|
||||
/* Hide project selector and back button on tablet (controlled by React rendering too) */
|
||||
.header-project-selector,
|
||||
.header-back-button,
|
||||
.header-projects-btn {
|
||||
/* Hide split project button on tablet (controlled by React rendering too) */
|
||||
.split-project-btn {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@@ -18335,103 +18331,85 @@ html .column.drag-over * {
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
/* === Project Selector === */
|
||||
.header-back-button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 6px 10px;
|
||||
border-radius: var(--radius-md);
|
||||
border: 1px solid var(--border);
|
||||
background: var(--surface);
|
||||
color: var(--text-muted);
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
transition: background var(--transition-fast), border-color var(--transition-fast), color var(--transition-fast);
|
||||
}
|
||||
|
||||
.header-back-button:hover {
|
||||
background: var(--card-hover);
|
||||
border-color: var(--text-dim);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.header-projects-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 6px 10px;
|
||||
border-radius: var(--radius-md);
|
||||
border: 1px solid var(--border);
|
||||
background: var(--surface);
|
||||
color: var(--text-muted);
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
transition: background var(--transition-fast), border-color var(--transition-fast), color var(--transition-fast);
|
||||
}
|
||||
|
||||
.header-projects-btn:hover {
|
||||
background: var(--card-hover);
|
||||
border-color: var(--text-dim);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.project-selector {
|
||||
/* === Split Project Button === */
|
||||
.split-project-btn {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.project-selector__trigger {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--space-sm);
|
||||
padding: 6px 10px;
|
||||
align-items: stretch;
|
||||
border-radius: var(--radius-md);
|
||||
border: 1px solid var(--border);
|
||||
background: var(--surface);
|
||||
color: var(--text);
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
transition: background var(--transition-fast), border-color var(--transition-fast);
|
||||
overflow: visible;
|
||||
transition: border-color var(--transition-fast);
|
||||
}
|
||||
|
||||
.project-selector__trigger:hover {
|
||||
background: var(--card-hover);
|
||||
.split-project-btn:hover {
|
||||
border-color: var(--text-dim);
|
||||
}
|
||||
|
||||
.project-selector__trigger.open {
|
||||
border-color: var(--todo);
|
||||
}
|
||||
|
||||
.project-selector__trigger-icon {
|
||||
flex-shrink: 0;
|
||||
.split-project-btn__left {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 6px 10px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.project-selector__trigger-text {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
transition: background var(--transition-fast), color var(--transition-fast);
|
||||
white-space: nowrap;
|
||||
border-radius: var(--radius-md) 0 0 var(--radius-md);
|
||||
}
|
||||
|
||||
.project-selector__trigger-chevron {
|
||||
flex-shrink: 0;
|
||||
.split-project-btn__left:hover {
|
||||
background: var(--card-hover);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.split-project-btn__divider {
|
||||
width: 1px;
|
||||
background: var(--border);
|
||||
align-self: stretch;
|
||||
margin: 4px 0;
|
||||
}
|
||||
|
||||
.split-project-btn__right {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 6px 6px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--text-muted);
|
||||
cursor: pointer;
|
||||
transition: background var(--transition-fast), color var(--transition-fast);
|
||||
border-radius: 0 var(--radius-md) var(--radius-md) 0;
|
||||
}
|
||||
|
||||
.split-project-btn__right:hover {
|
||||
background: var(--card-hover);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.split-project-btn__right--open {
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.split-project-btn__chevron {
|
||||
transition: transform var(--transition-fast);
|
||||
}
|
||||
|
||||
.project-selector__trigger-chevron.rotate {
|
||||
.split-project-btn__chevron--open {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
.project-selector__dropdown {
|
||||
.split-project-btn__dropdown {
|
||||
position: absolute;
|
||||
top: calc(100% + 8px);
|
||||
right: 0;
|
||||
left: 0;
|
||||
z-index: 100;
|
||||
min-width: 280px;
|
||||
min-width: 240px;
|
||||
max-width: 360px;
|
||||
padding: var(--space-sm);
|
||||
border: 1px solid var(--border);
|
||||
@@ -18440,76 +18418,7 @@ html .column.drag-over * {
|
||||
box-shadow: var(--shadow-lg);
|
||||
}
|
||||
|
||||
.project-selector__search {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-sm);
|
||||
padding: 6px 10px;
|
||||
margin-bottom: var(--space-sm);
|
||||
background: var(--card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
|
||||
.project-selector__search-icon {
|
||||
flex-shrink: 0;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.project-selector__search-input {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--text);
|
||||
font-size: 13px;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.project-selector__search-input::placeholder {
|
||||
color: var(--text-dim);
|
||||
}
|
||||
|
||||
.project-selector__search-clear {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: var(--space-xs);
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--text-muted);
|
||||
cursor: pointer;
|
||||
border-radius: var(--radius-sm);
|
||||
transition: background var(--transition-fast), color var(--transition-fast);
|
||||
}
|
||||
|
||||
.project-selector__search-clear:hover {
|
||||
background: var(--border);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.project-selector__section {
|
||||
margin-bottom: var(--space-sm);
|
||||
}
|
||||
|
||||
.project-selector__section:last-of-type {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.project-selector__section-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 6px 8px;
|
||||
margin-bottom: var(--space-xs);
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: var(--text-muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
.project-selector__item {
|
||||
.split-project-btn__item {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -18525,30 +18434,22 @@ html .column.drag-over * {
|
||||
transition: background var(--transition-fast);
|
||||
}
|
||||
|
||||
.project-selector__item:hover {
|
||||
.split-project-btn__item:hover {
|
||||
background: var(--card-hover);
|
||||
}
|
||||
|
||||
.project-selector__item.highlighted {
|
||||
.split-project-btn__item--current {
|
||||
background: var(--card-hover);
|
||||
outline: 1px solid var(--todo);
|
||||
}
|
||||
|
||||
.project-selector__item-name {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.project-selector__item-check {
|
||||
.split-project-btn__item-dot {
|
||||
flex-shrink: 0;
|
||||
color: var(--todo);
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.project-selector__item-info {
|
||||
.split-project-btn__item-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
@@ -18556,7 +18457,14 @@ html .column.drag-over * {
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.project-selector__item-path {
|
||||
.split-project-btn__item-name {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.split-project-btn__item-path {
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
overflow: hidden;
|
||||
@@ -18564,80 +18472,36 @@ html .column.drag-over * {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.project-selector__no-results {
|
||||
padding: 12px 10px;
|
||||
font-size: 13px;
|
||||
color: var(--text-dim);
|
||||
text-align: center;
|
||||
.split-project-btn__item-check {
|
||||
flex-shrink: 0;
|
||||
color: var(--todo);
|
||||
}
|
||||
|
||||
.project-selector__footer {
|
||||
margin-top: var(--space-sm);
|
||||
padding-top: 8px;
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.project-selector__view-all {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: var(--space-sm);
|
||||
padding: 8px 10px;
|
||||
background: transparent;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-md);
|
||||
color: var(--text-muted);
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
transition: background var(--transition-fast), border-color var(--transition-fast), color var(--transition-fast);
|
||||
}
|
||||
|
||||
.project-selector__view-all:hover {
|
||||
background: var(--card-hover);
|
||||
border-color: var(--text-dim);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.project-selector__view-all.highlighted {
|
||||
background: var(--card-hover);
|
||||
border-color: var(--todo);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
/* Light theme overrides */
|
||||
[data-theme="light"] .project-selector__trigger {
|
||||
/* Light theme overrides for split project button */
|
||||
[data-theme="light"] .split-project-btn {
|
||||
background: var(--surface);
|
||||
}
|
||||
|
||||
[data-theme="light"] .project-selector__trigger:hover {
|
||||
[data-theme="light"] .split-project-btn__left:hover {
|
||||
background: var(--card-hover);
|
||||
}
|
||||
|
||||
[data-theme="light"] .project-selector__dropdown {
|
||||
[data-theme="light"] .split-project-btn__right:hover {
|
||||
background: var(--card-hover);
|
||||
}
|
||||
|
||||
[data-theme="light"] .split-project-btn__dropdown {
|
||||
background: var(--surface);
|
||||
}
|
||||
|
||||
[data-theme="light"] .project-selector__search {
|
||||
background: var(--card);
|
||||
}
|
||||
|
||||
[data-theme="light"] .project-selector__item:hover {
|
||||
[data-theme="light"] .split-project-btn__item:hover {
|
||||
background: var(--card-hover);
|
||||
}
|
||||
|
||||
[data-theme="light"] .project-selector__item.highlighted {
|
||||
[data-theme="light"] .split-project-btn__item--current {
|
||||
background: rgba(88, 166, 255, 0.1);
|
||||
}
|
||||
|
||||
[data-theme="light"] .project-selector__view-all {
|
||||
border-color: var(--border);
|
||||
}
|
||||
|
||||
[data-theme="light"] .project-selector__view-all:hover {
|
||||
background: var(--card-hover);
|
||||
}
|
||||
|
||||
/* === Project Content Wrapper (footer-safe layout) === */
|
||||
|
||||
/**
|
||||
|
||||
@@ -5773,7 +5773,8 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
|
||||
throw badRequest("command exceeds maximum length of 4096 characters");
|
||||
}
|
||||
|
||||
const rootDir = store.getRootDir();
|
||||
const scopedStore = await getScopedStore(req);
|
||||
const rootDir = scopedStore.getRootDir();
|
||||
const result = terminalSessionManager.createSession(command, rootDir);
|
||||
|
||||
if (result.error) {
|
||||
@@ -5927,7 +5928,8 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
|
||||
router.post("/terminal/sessions", async (req, res) => {
|
||||
try {
|
||||
const { cwd, cols, rows } = req.body;
|
||||
const terminalService = getTerminalService(store.getRootDir());
|
||||
const scopedStore = await getScopedStore(req);
|
||||
const terminalService = getTerminalService(scopedStore.getRootDir());
|
||||
|
||||
const result = await terminalService.createSession({
|
||||
cwd,
|
||||
@@ -5964,9 +5966,10 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
|
||||
* List all active PTY terminal sessions.
|
||||
* Returns: [{ id: string, cwd: string, shell: string, createdAt: string }]
|
||||
*/
|
||||
router.get("/terminal/sessions", async (_req, res) => {
|
||||
router.get("/terminal/sessions", async (req, res) => {
|
||||
try {
|
||||
const terminalService = getTerminalService(store.getRootDir());
|
||||
const scopedStore = await getScopedStore(req);
|
||||
const terminalService = getTerminalService(scopedStore.getRootDir());
|
||||
const sessions = terminalService.getAllSessions();
|
||||
|
||||
res.json(
|
||||
@@ -5991,10 +5994,11 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
|
||||
* Kill a PTY terminal session.
|
||||
* Returns: { killed: boolean }
|
||||
*/
|
||||
router.delete("/terminal/sessions/:id", (req, res) => {
|
||||
router.delete("/terminal/sessions/:id", async (req, res) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const terminalService = getTerminalService(store.getRootDir());
|
||||
const scopedStore = await getScopedStore(req);
|
||||
const terminalService = getTerminalService(scopedStore.getRootDir());
|
||||
|
||||
const killed = terminalService.killSession(id);
|
||||
|
||||
@@ -6118,11 +6122,12 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
|
||||
* List available file browser workspaces.
|
||||
* Returns: { project: string; tasks: Array<{ id: string; title?: string; worktree: string }> }
|
||||
*/
|
||||
router.get("/workspaces", async (_req, res) => {
|
||||
router.get("/workspaces", async (req, res) => {
|
||||
try {
|
||||
const tasks = await store.listTasks({ slim: true, includeArchived: false });
|
||||
const scopedStore = await getScopedStore(req);
|
||||
const tasks = await scopedStore.listTasks({ slim: true, includeArchived: false });
|
||||
res.json({
|
||||
project: store.getRootDir(),
|
||||
project: scopedStore.getRootDir(),
|
||||
tasks: tasks
|
||||
.filter((task) => typeof task.worktree === "string" && task.worktree.length > 0 && existsSync(task.worktree))
|
||||
.map((task) => ({
|
||||
@@ -6147,9 +6152,10 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
|
||||
*/
|
||||
router.get("/files", async (req, res) => {
|
||||
try {
|
||||
const scopedStore = await getScopedStore(req);
|
||||
const { path: subPath, workspace } = req.query;
|
||||
const workspaceId = typeof workspace === "string" && workspace.length > 0 ? workspace : "project";
|
||||
const result = await listWorkspaceFiles(store, workspaceId, typeof subPath === "string" ? subPath : undefined);
|
||||
const result = await listWorkspaceFiles(scopedStore, workspaceId, typeof subPath === "string" ? subPath : undefined);
|
||||
res.json(result);
|
||||
} catch (err: any) {
|
||||
if (err instanceof ApiError) {
|
||||
@@ -6175,11 +6181,12 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
|
||||
*/
|
||||
router.get("/files/{*filepath}", async (req, res) => {
|
||||
try {
|
||||
const scopedStore = await getScopedStore(req);
|
||||
const filePath = Array.isArray(req.params.filepath) ? req.params.filepath[0] : req.params.filepath ?? "";
|
||||
const workspace = typeof req.query.workspace === "string" && req.query.workspace.length > 0
|
||||
? req.query.workspace
|
||||
: "project";
|
||||
const result = await readWorkspaceFile(store, workspace, filePath);
|
||||
const result = await readWorkspaceFile(scopedStore, workspace, filePath);
|
||||
res.json(result);
|
||||
} catch (err: any) {
|
||||
if (err instanceof ApiError) {
|
||||
@@ -6208,17 +6215,18 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
|
||||
*/
|
||||
router.post("/files/{*filepath}", async (req, res) => {
|
||||
try {
|
||||
const scopedStore = await getScopedStore(req);
|
||||
const filePath = Array.isArray(req.params.filepath) ? req.params.filepath[0] : req.params.filepath ?? "";
|
||||
const { content } = req.body;
|
||||
const workspace = typeof req.query.workspace === "string" && req.query.workspace.length > 0
|
||||
? req.query.workspace
|
||||
: "project";
|
||||
|
||||
|
||||
if (typeof content !== "string") {
|
||||
throw badRequest("content is required and must be a string");
|
||||
}
|
||||
|
||||
const result = await writeWorkspaceFile(store, workspace, filePath, content);
|
||||
const result = await writeWorkspaceFile(scopedStore, workspace, filePath, content);
|
||||
res.json(result);
|
||||
} catch (err: any) {
|
||||
if (err instanceof ApiError) {
|
||||
@@ -6259,6 +6267,7 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
|
||||
*/
|
||||
router.post("/files/{*filepath}/copy", async (req, res) => {
|
||||
try {
|
||||
const scopedStore = await getScopedStore(req);
|
||||
const { filePath, workspace } = extractFileParams(req);
|
||||
const { destination } = req.body;
|
||||
|
||||
@@ -6266,7 +6275,7 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
|
||||
throw badRequest("destination is required and must be a string");
|
||||
}
|
||||
|
||||
const result = await copyWorkspaceFile(store, workspace, filePath, destination);
|
||||
const result = await copyWorkspaceFile(scopedStore, workspace, filePath, destination);
|
||||
res.json(result);
|
||||
} catch (err: any) {
|
||||
if (err instanceof ApiError) {
|
||||
@@ -6294,6 +6303,7 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
|
||||
*/
|
||||
router.post("/files/{*filepath}/move", async (req, res) => {
|
||||
try {
|
||||
const scopedStore = await getScopedStore(req);
|
||||
const { filePath, workspace } = extractFileParams(req);
|
||||
const { destination } = req.body;
|
||||
|
||||
@@ -6301,7 +6311,7 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
|
||||
throw badRequest("destination is required and must be a string");
|
||||
}
|
||||
|
||||
const result = await moveWorkspaceFile(store, workspace, filePath, destination);
|
||||
const result = await moveWorkspaceFile(scopedStore, workspace, filePath, destination);
|
||||
res.json(result);
|
||||
} catch (err: any) {
|
||||
if (err instanceof ApiError) {
|
||||
@@ -6330,8 +6340,9 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
|
||||
*/
|
||||
router.post("/files/{*filepath}/delete", async (req, res) => {
|
||||
try {
|
||||
const scopedStore = await getScopedStore(req);
|
||||
const { filePath, workspace } = extractFileParams(req);
|
||||
const result = await deleteWorkspaceFile(store, workspace, filePath);
|
||||
const result = await deleteWorkspaceFile(scopedStore, workspace, filePath);
|
||||
res.json(result);
|
||||
} catch (err: any) {
|
||||
if (err instanceof ApiError) {
|
||||
@@ -6358,6 +6369,7 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
|
||||
*/
|
||||
router.post("/files/{*filepath}/rename", async (req, res) => {
|
||||
try {
|
||||
const scopedStore = await getScopedStore(req);
|
||||
const { filePath, workspace } = extractFileParams(req);
|
||||
const { newName } = req.body;
|
||||
|
||||
@@ -6365,7 +6377,7 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
|
||||
throw badRequest("newName is required and must be a string");
|
||||
}
|
||||
|
||||
const result = await renameWorkspaceFile(store, workspace, filePath, newName);
|
||||
const result = await renameWorkspaceFile(scopedStore, workspace, filePath, newName);
|
||||
res.json(result);
|
||||
} catch (err: any) {
|
||||
if (err instanceof ApiError) {
|
||||
@@ -6392,8 +6404,9 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
|
||||
*/
|
||||
router.get("/files/{*filepath}/download", async (req, res) => {
|
||||
try {
|
||||
const scopedStore = await getScopedStore(req);
|
||||
const { filePath, workspace } = extractFileParams(req);
|
||||
const { absolutePath, stats, fileName } = await getWorkspaceFileForDownload(store, workspace, filePath);
|
||||
const { absolutePath, stats, fileName } = await getWorkspaceFileForDownload(scopedStore, workspace, filePath);
|
||||
|
||||
res.setHeader("Content-Type", "application/octet-stream");
|
||||
res.setHeader("Content-Disposition", `attachment; filename="${fileName}"`);
|
||||
@@ -6427,8 +6440,9 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
|
||||
*/
|
||||
router.get("/files/{*filepath}/download-zip", async (req, res) => {
|
||||
try {
|
||||
const scopedStore = await getScopedStore(req);
|
||||
const { filePath, workspace } = extractFileParams(req);
|
||||
const { absolutePath, dirName } = await getWorkspaceFolderForZip(store, workspace, filePath);
|
||||
const { absolutePath, dirName } = await getWorkspaceFolderForZip(scopedStore, workspace, filePath);
|
||||
|
||||
const archiver = await import("archiver");
|
||||
const archive = archiver.default("zip", { zlib: { level: 6 } });
|
||||
|
||||
Reference in New Issue
Block a user