Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
164 changes: 164 additions & 0 deletions dashboard/src/v2/components/settings/LocalFilePickerField.tsx
Original file line number Diff line number Diff line change
@@ -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<LocalFileBrowserResponse | null>(null);
const [error, setError] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState(false);

const loadFiles = async (directoryPath?: string): Promise<void> => {
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 (
<div className="flex min-w-0 flex-col gap-2">
<div className="flex min-w-0 flex-col gap-2 sm:flex-row sm:items-start">
<div className="min-w-0 flex-1">
<TextInput
value={value}
onChange={onChange}
placeholder={placeholder}
helperText={helperText}
aria-label={label}
mono
/>
</div>
<button
type="button"
onClick={isOpen ? () => setIsOpen(false) : openPicker}
aria-expanded={isOpen}
aria-controls={pickerId}
className="inline-flex shrink-0 items-center justify-center gap-2 rounded-[var(--radius-ui)] border border-[color:var(--border-hairline)] bg-void-900 px-4 py-2.5 text-xs font-black uppercase tracking-[0.14em] text-white transition-[background-color,border-color,color,box-shadow,transform] hover:-translate-y-px hover:bg-void-800 focus:outline-none focus-visible:ring-2 focus-visible:ring-[var(--accent-focus-ring)] focus-visible:ring-offset-2 focus-visible:ring-offset-white active:scale-[0.98] dark:border-white/[0.08] dark:bg-white/[0.08] dark:text-white dark:hover:bg-white/[0.12] dark:focus-visible:ring-offset-void-900"
>
{isOpen ? <X aria-hidden="true" className="h-4 w-4" /> : <FolderOpen aria-hidden="true" className="h-4 w-4" />}
{isOpen ? "Close" : "Browse"}
</button>
</div>

{isOpen ? (
<div
id={pickerId}
className="overflow-hidden rounded-[var(--radius-ui)] border border-[color:var(--border-hairline)] bg-[var(--fill-muted)]"
>
<div className="flex min-w-0 flex-wrap items-center gap-2 border-b border-[color:var(--border-hairline)] px-3 py-2.5">
<button
type="button"
onClick={() => listing?.parentPath && void loadFiles(listing.parentPath)}
disabled={!listing?.parentPath || isLoading}
className="flex h-8 w-8 items-center justify-center rounded-xl bg-white text-slate-500 shadow-sm transition-colors hover:text-slate-900 disabled:cursor-not-allowed disabled:opacity-40 dark:bg-white/[0.06] dark:text-slate-300 dark:hover:text-white"
aria-label={`${label}: go to parent directory`}
title="Go to parent directory"
>
<ChevronUp aria-hidden="true" className="h-4 w-4" />
</button>
<button
type="button"
onClick={() => void loadFiles(listing?.homePath)}
disabled={isLoading}
className="flex h-8 w-8 items-center justify-center rounded-xl bg-white text-slate-500 shadow-sm transition-colors hover:text-slate-900 disabled:cursor-not-allowed disabled:opacity-40 dark:bg-white/[0.06] dark:text-slate-300 dark:hover:text-white"
aria-label={`${label}: go to home directory`}
title="Go to home directory"
>
<Home aria-hidden="true" className="h-4 w-4" />
</button>
<button
type="button"
onClick={() => void loadFiles(refreshPath)}
disabled={isLoading}
aria-busy={isLoading}
className="flex h-8 w-8 items-center justify-center rounded-xl bg-white text-slate-500 shadow-sm transition-colors hover:text-slate-900 disabled:cursor-not-allowed disabled:opacity-40 dark:bg-white/[0.06] dark:text-slate-300 dark:hover:text-white"
aria-label={`${label}: refresh current path`}
title="Refresh current path"
>
<RefreshCw aria-hidden="true" className={`h-4 w-4 ${isLoading ? "animate-spin" : ""}`} />
{isLoading ? <span className="sr-only">Loading files</span> : null}
</button>
<div className="min-w-0 flex-1 truncate rounded-xl bg-white px-3 py-2 font-mono text-xs font-semibold text-slate-600 dark:bg-white/[0.055] dark:text-slate-300">
{listing?.currentPath || "Loading files..."}
</div>
</div>

{error ? (
<div role="alert" aria-live="assertive" className="flex items-center gap-2 px-3 py-3 text-xs font-semibold text-status-red">
<AlertCircle aria-hidden="true" className="h-4 w-4 shrink-0" />
<span>{error}</span>
</div>
) : (
<div className="max-h-56 overflow-y-auto p-2">
{isLoading && !listing ? (
<div className="flex items-center gap-2 px-2 py-3 text-xs font-semibold text-slate-500 dark:text-slate-400">
<Loader2 aria-hidden="true" className="h-4 w-4 animate-spin" />
Loading files
</div>
) : listing ? (
<div className="grid gap-1">
{listing.directories.map((directory) => (
<button
key={directory.path}
type="button"
onClick={() => void loadFiles(directory.path)}
className="flex min-w-0 items-center gap-2 rounded-xl px-2.5 py-2 text-left font-mono text-xs font-semibold text-slate-600 transition-colors hover:bg-white hover:text-slate-900 focus:outline-none focus-visible:ring-2 focus-visible:ring-[var(--accent-focus-ring)] dark:text-slate-300 dark:hover:bg-white/[0.06] dark:hover:text-white"
>
<FolderOpen aria-hidden="true" className="h-4 w-4 shrink-0 text-signal-600 dark:text-signal-300" />
<span className="truncate">{directory.name}</span>
</button>
))}
{listing.files.map((file) => (
<button
key={file.path}
type="button"
onClick={() => onChange(file.path)}
className="flex min-w-0 items-center gap-2 rounded-xl px-2.5 py-2 text-left font-mono text-xs font-semibold text-slate-700 transition-colors hover:bg-white hover:text-slate-950 focus:outline-none focus-visible:ring-2 focus-visible:ring-[var(--accent-focus-ring)] dark:text-slate-200 dark:hover:bg-white/[0.06] dark:hover:text-white"
>
<FileText aria-hidden="true" className="h-4 w-4 shrink-0 text-ember-600 dark:text-ember-300" />
<span className="truncate">{file.name}</span>
{value === file.path ? <Check aria-hidden="true" className="ml-auto h-4 w-4 shrink-0 text-signal-600 dark:text-signal-300" /> : null}
</button>
))}
{!listing.directories.length && !listing.files.length ? (
<div className="px-2 py-3 text-xs font-semibold text-slate-500 dark:text-slate-400">
No child directories or files
</div>
) : null}
</div>
) : (
<div className="px-2 py-3 text-xs font-semibold text-slate-500 dark:text-slate-400">
Open the picker to browse local files.
</div>
)}
</div>
)}
</div>
) : null}
</div>
);
};
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -348,15 +349,17 @@ export const ProjectSettingsEditor: FunctionComponent<ProjectSettingsEditorProps
/>
</Row>
<Row label="Setup script path" description="Optional bootstrap script relative to the repo or runtime root." badge={getBadge("cliWorkflow.containerSetupScriptPath")}>
<TextInput
<LocalFilePickerField
label="Setup script path"
value={settings.cliWorkflow.containerSetupScriptPath}
onChange={(value) => update({
cliWorkflow: {
...settings.cliWorkflow,
containerSetupScriptPath: value,
},
})}
mono
helperText="Type a relative path or browse to an absolute local script."
placeholder=".code-ux/container/setup.sh"
/>
</Row>
<Row label="Cache setup as image" description="Build and reuse a derived Docker image keyed by the base image and setup script contents." badge={getBadge("cliWorkflow.containerCacheSetupScriptImage")}>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -201,7 +202,8 @@ const DockerRuntimeCard: FunctionComponent<{
/>
</Row>
<Row label="Container setup script" description="Optional setup script run inside the container before task execution." badge={getFieldBadge("cliWorkflow.containerSetupScriptPath")}>
<TextInput
<LocalFilePickerField
label="Container setup script"
value={settings.cliWorkflow.containerSetupScriptPath}
onChange={(value) => update((current) => ({
...current,
Expand All @@ -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"
/>
</Row>
<Row label="Cache setup as image" description="Build and reuse a derived Docker image from the base image plus setup script contents." badge={getFieldBadge("cliWorkflow.containerCacheSetupScriptImage")}>
Expand Down
1 change: 1 addition & 0 deletions docs/dashboard/design-system-settings.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
46 changes: 46 additions & 0 deletions tests/dashboard/settings-page.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down Expand Up @@ -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(
<ProjectSettingsEditor
settings={settings}
onChange={mockOnChange}
/>
);

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" }),
}));
});
});
Loading