diff --git a/dashboard/src/v2/components/settings/LocalFilePickerField.tsx b/dashboard/src/v2/components/settings/LocalFilePickerField.tsx new file mode 100644 index 0000000000..414ff01b90 --- /dev/null +++ b/dashboard/src/v2/components/settings/LocalFilePickerField.tsx @@ -0,0 +1,164 @@ +import type { FunctionComponent } from "preact"; +import { useId, useState } from "preact/hooks"; +import { AlertCircle, Check, ChevronUp, FileText, FolderOpen, Home, Loader2, RefreshCw, X } from "lucide-preact"; +import type { LocalFileBrowserResponse } from "../../types.js"; +import { fetchLocalFiles } from "../../lib/project-api.js"; +import { TextInput } from "./SettingsFormFields.js"; + +export const LocalFilePickerField: FunctionComponent<{ + value: string; + onChange: (value: string) => void; + label: string; + helperText?: string; + placeholder?: string; +}> = ({ value, onChange, label, helperText, placeholder }) => { + const generatedId = useId(); + const pickerId = `${generatedId}-picker`; + const [isOpen, setIsOpen] = useState(false); + const [listing, setListing] = useState(null); + const [error, setError] = useState(null); + const [isLoading, setIsLoading] = useState(false); + + const loadFiles = async (directoryPath?: string): Promise => { + setIsLoading(true); + setError(null); + try { + const nextListing = await fetchLocalFiles(directoryPath); + setListing(nextListing); + } catch (err) { + setError(err instanceof Error ? err.message : String(err)); + } finally { + setIsLoading(false); + } + }; + + const openPicker = (): void => { + setIsOpen(true); + void loadFiles(value.trim() || undefined); + }; + + const refreshPath = listing?.currentPath || value.trim() || undefined; + + return ( +
+
+
+ +
+ +
+ + {isOpen ? ( +
+
+ + + +
+ {listing?.currentPath || "Loading files..."} +
+
+ + {error ? ( +
+
+ ) : ( +
+ {isLoading && !listing ? ( +
+
+ ) : listing ? ( +
+ {listing.directories.map((directory) => ( + + ))} + {listing.files.map((file) => ( + + ))} + {!listing.directories.length && !listing.files.length ? ( +
+ No child directories or files +
+ ) : null} +
+ ) : ( +
+ Open the picker to browse local files. +
+ )} +
+ )} +
+ ) : null} +
+ ); +}; diff --git a/dashboard/src/v2/components/settings/ProjectSettingsEditor.tsx b/dashboard/src/v2/components/settings/ProjectSettingsEditor.tsx index f46984549a..61094c9690 100644 --- a/dashboard/src/v2/components/settings/ProjectSettingsEditor.tsx +++ b/dashboard/src/v2/components/settings/ProjectSettingsEditor.tsx @@ -21,6 +21,7 @@ import { ProviderPanel } from "./panels/ProviderPanel.js"; import { WorkerPanel } from "./panels/WorkerPanel.js"; import { InfoIconPopover } from "../ui/InfoIconPopover.js"; import { BranchNameSchemeEditor } from "./BranchNameSchemeEditor.js"; +import { LocalFilePickerField } from "./LocalFilePickerField.js"; export interface ProjectSettingsEditorProps { @@ -348,7 +349,8 @@ export const ProjectSettingsEditor: FunctionComponent - update({ cliWorkflow: { @@ -356,7 +358,8 @@ export const ProjectSettingsEditor: FunctionComponent diff --git a/dashboard/src/v2/components/settings/panels/SettingsGeneralPanel.tsx b/dashboard/src/v2/components/settings/panels/SettingsGeneralPanel.tsx index 2b0f8f5e8f..e76e8bcaa2 100644 --- a/dashboard/src/v2/components/settings/panels/SettingsGeneralPanel.tsx +++ b/dashboard/src/v2/components/settings/panels/SettingsGeneralPanel.tsx @@ -4,6 +4,7 @@ import type { SettingsPageState } from "../../../hooks/use-settings-page-state.j import { ActionButton, NoticePanel } from "../SettingsSurface.js"; import { ActionFeedbackRegion } from "../../ui/ActionFeedbackRegion.js"; import { NumberInput, Row, Toggle, TextInput, PillChoiceGroup } from "../SettingsFormFields.js"; +import { LocalFilePickerField } from "../LocalFilePickerField.js"; import type { ProjectSettings } from "../../../../../../src/contracts/settings-scope-types.js"; import { SectionCard, getBadge as getBadgeHelper, getFieldBadge as getFieldBadgeHelper } from "./SharedPanelComponents.js"; import { Bot, Cog, Database, FolderOpen, Sparkles } from "lucide-preact"; @@ -201,7 +202,8 @@ const DockerRuntimeCard: FunctionComponent<{ /> - update((current) => ({ ...current, @@ -210,7 +212,8 @@ const DockerRuntimeCard: FunctionComponent<{ containerSetupScriptPath: value, }, }))} - mono + helperText="Type a relative path or browse to an absolute local script." + placeholder=".code-ux/container/setup.sh" /> diff --git a/docs/dashboard/design-system-settings.md b/docs/dashboard/design-system-settings.md index 63b415201f..69a06c5ec9 100644 --- a/docs/dashboard/design-system-settings.md +++ b/docs/dashboard/design-system-settings.md @@ -70,6 +70,7 @@ This document defines the visual patterns and rules for the Settings workspace. * Active panel saving/loading/resetting sets `aria-busy` at the panel boundary and keeps field values mounted. Background work uses `ActionFeedbackRegion` plus a screen-reader status/alert; do not replace populated panels with placeholder-only loading states. * Category rail selection uses `selectionMovement`, `aria-current`, `aria-selected`, `aria-busy`, active rail styling, and a polite search/result status. Do not add visible Selected or Pending badges to category rows. Disabled category switches must retain a stable label and expose the disabled reason through `title`, `aria-describedby`, and visible disabled copy. * Disabled form controls must not disappear. Place durable helper text next to the affected control, wire it through `aria-describedby`, and keep the label/value visible so users know what will become editable after recovery. +* Settings path fields that support local browsing use the shared file-picker field: keep the manual text input editable for empty, relative, and absolute paths; expose Browse/Close as buttons with `aria-expanded` and `aria-controls`; provide parent, home, and typed/current-path refresh controls; render loading and empty states as visible text; and keep API failures in a persistent `role="alert"` without clearing the typed value. * Save controls and provider-card actions suppress duplicate activation while pending. Destructive provider or danger-zone actions require explicit confirmation and must restore focus to the initiating control or a stable panel fallback. ## Verification Notes diff --git a/tests/dashboard/settings-page.test.tsx b/tests/dashboard/settings-page.test.tsx index de6fe0871c..30ffc917ab 100644 --- a/tests/dashboard/settings-page.test.tsx +++ b/tests/dashboard/settings-page.test.tsx @@ -6,21 +6,30 @@ import { fireEvent } from "@testing-library/preact"; import { describe, it, expect, vi, afterEach } from "vitest"; import { ProjectSettingsEditor } from "../../dashboard/src/v2/components/settings/ProjectSettingsEditor.jsx"; import { TextInput } from "../../dashboard/src/v2/components/settings/SettingsFormFields.js"; +import { fetchLocalFiles } from "../../dashboard/src/v2/lib/project-api.js"; +import { cloneProjectSettings } from "../../dashboard/src/v2/lib/settings/project-overrides.js"; +import { DEFAULT_DASHBOARD_SETTINGS } from "../../src/repositories/settings-defaults.js"; import * as matchers from '@testing-library/jest-dom/matchers'; expect.extend(matchers); +vi.mock("../../dashboard/src/v2/lib/project-api.js", () => ({ + fetchLocalFiles: vi.fn(), +})); + describe("ProjectSettingsEditor", () => { const originalMatchMedia = window.matchMedia; afterEach(() => { cleanup(); window.matchMedia = originalMatchMedia; + vi.mocked(fetchLocalFiles).mockReset(); }); it("renders Max Parsing Retries input and passes updates correctly", async () => { const mockOnChange = vi.fn(); const mockSettings = { cliWorkflow: { + ...cloneProjectSettings(DEFAULT_DASHBOARD_SETTINGS).cliWorkflow, maxParsingRetries: 3 }, workers: { @@ -121,4 +130,41 @@ describe("ProjectSettingsEditor", () => { const counter = screen.getByText("10 / 10"); expect(counter).toHaveStyle({ animationDuration: "0ms" }); }); + + it("uses the local file picker for setup script path updates", async () => { + const settings = cloneProjectSettings(DEFAULT_DASHBOARD_SETTINGS); + settings.cliWorkflow.containerSetupScriptPath = ".code-ux/container/setup.sh"; + const mockOnChange = vi.fn(); + vi.mocked(fetchLocalFiles).mockResolvedValueOnce({ + currentPath: "/workspace/test-project", + parentPath: "/workspace", + rootPath: "/", + homePath: "/home/user", + directories: [], + files: [{ name: "setup.sh", path: "/workspace/test-project/setup.sh" }], + }); + + render( + + ); + + fireEvent.input(screen.getByLabelText("Setup script path"), { + target: { value: "scripts/container/setup.sh" }, + }); + expect(mockOnChange).toHaveBeenCalledWith(expect.objectContaining({ + cliWorkflow: expect.objectContaining({ containerSetupScriptPath: "scripts/container/setup.sh" }), + })); + + fireEvent.click(screen.getByRole("button", { name: "Browse" })); + expect(await screen.findByText("/workspace/test-project")).toBeInTheDocument(); + + fireEvent.click(screen.getByRole("button", { name: "setup.sh" })); + + expect(mockOnChange).toHaveBeenCalledWith(expect.objectContaining({ + cliWorkflow: expect.objectContaining({ containerSetupScriptPath: "/workspace/test-project/setup.sh" }), + })); + }); }); diff --git a/tests/dashboard/v2/settings-general-panel.test.tsx b/tests/dashboard/v2/settings-general-panel.test.tsx index 2f8658319d..01e2d0cdd3 100644 --- a/tests/dashboard/v2/settings-general-panel.test.tsx +++ b/tests/dashboard/v2/settings-general-panel.test.tsx @@ -1,12 +1,14 @@ /** @vitest-environment happy-dom */ /** @jsx h */ -/** @jsxFrag Fragment */ -import { h, Fragment } from "preact"; +import { h } from "preact"; +import { useState } from "preact/hooks"; import { afterEach, describe, expect, it, vi, beforeEach } from "vitest"; import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/preact"; import * as matchers from "@testing-library/jest-dom/matchers"; import { SettingsGeneralPanel } from "../../../dashboard/src/v2/components/settings/panels/SettingsGeneralPanel.js"; import { useProjectData } from "../../../dashboard/src/v2/context/project-data.js"; +import { fetchLocalFiles } from "../../../dashboard/src/v2/lib/project-api.js"; +import { cloneProjectSettings } from "../../../dashboard/src/v2/lib/settings/project-overrides.js"; import { DEFAULT_DASHBOARD_SETTINGS } from "../../../src/repositories/settings-defaults.js"; expect.extend(matchers); @@ -24,7 +26,11 @@ vi.mock("../../../dashboard/src/v2/lib/onboarding-control.js", () => ({ openOnboarding: vi.fn(), })); -const cloneSettings = () => JSON.parse(JSON.stringify(DEFAULT_DASHBOARD_SETTINGS)); +vi.mock("../../../dashboard/src/v2/lib/project-api.js", () => ({ + fetchLocalFiles: vi.fn(), +})); + +const cloneSettings = () => cloneProjectSettings(DEFAULT_DASHBOARD_SETTINGS); const createProjectState = () => ({ activeScope: "project", @@ -45,6 +51,7 @@ const createProjectState = () => ({ describe("SettingsGeneralPanel", () => { beforeEach(() => { vi.clearAllMocks(); + vi.mocked(fetchLocalFiles).mockReset(); }); afterEach(() => { @@ -86,4 +93,87 @@ describe("SettingsGeneralPanel", () => { expect(saveButton).toHaveAttribute("title", "Enter a project name before saving."); expect(updateProject).not.toHaveBeenCalled(); }); + + it("browses local files and updates the container setup script path", async () => { + vi.mocked(useProjectData).mockReturnValue({ updateProject: vi.fn() } as any); + vi.mocked(fetchLocalFiles) + .mockResolvedValueOnce({ + currentPath: "/workspace/test-project", + parentPath: "/workspace", + rootPath: "/", + homePath: "/home/user", + directories: [{ name: ".code-ux", path: "/workspace/test-project/.code-ux" }], + files: [], + }) + .mockResolvedValueOnce({ + currentPath: "/workspace/test-project/.code-ux", + parentPath: "/workspace/test-project", + rootPath: "/", + homePath: "/home/user", + directories: [], + files: [{ name: "setup.sh", path: "/workspace/test-project/.code-ux/setup.sh" }], + }); + + const StatefulHarness = () => { + const [settings, setSettings] = useState(cloneSettings()); + return setSettings((current: any) => recipe(current)), + projectSources: {}, + } as any} />; + }; + + render(); + + fireEvent.click(screen.getByRole("button", { name: "Browse" })); + expect(await screen.findByText("/workspace/test-project")).toBeInTheDocument(); + + fireEvent.click(screen.getByRole("button", { name: ".code-ux" })); + expect(await screen.findByText("/workspace/test-project/.code-ux")).toBeInTheDocument(); + + fireEvent.click(screen.getByRole("button", { name: "setup.sh" })); + + expect(screen.getByLabelText("Container setup script")).toHaveValue("/workspace/test-project/.code-ux/setup.sh"); + }); + + it("keeps manual container setup script text when file browsing fails", async () => { + vi.mocked(useProjectData).mockReturnValue({ updateProject: vi.fn() } as any); + vi.mocked(fetchLocalFiles).mockRejectedValueOnce(new Error("Path is outside allowed roots")); + + const StatefulHarness = () => { + const initialSettings = cloneSettings(); + const [settings, setSettings] = useState({ + ...initialSettings, + cliWorkflow: { + ...initialSettings.cliWorkflow, + containerSetupScriptPath: ".code-ux/container/setup.sh", + }, + }); + return setSettings((current: any) => recipe(current)), + projectSources: {}, + } as any} />; + }; + + render(); + + const input = screen.getByLabelText("Container setup script"); + expect(input).toHaveValue(".code-ux/container/setup.sh"); + + fireEvent.click(screen.getByRole("button", { name: "Browse" })); + + expect(await screen.findByRole("alert")).toHaveTextContent("Path is outside allowed roots"); + expect(input).toHaveValue(".code-ux/container/setup.sh"); + }); });