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
46 changes: 46 additions & 0 deletions src/components/controls/SearchField.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import type { KeyboardEventHandler, Ref } from "react";
import { Search } from "lucide-react";

import { cn } from "@/lib/utils";
import { Input } from "@/components/ui/input";
import { COMPACT_CONTROL } from "./controlHeight";

interface Props {
value: string;
onChange: (value: string) => void;
placeholder?: string;
label?: string;
className?: string;
ref?: Ref<HTMLInputElement>;
onKeyDown?: KeyboardEventHandler<HTMLInputElement>;
}

// A compact search input: the magnifier and the shared control height in one
// place, so every dense header search looks and sizes the same.
export function SearchField({
value,
onChange,
placeholder = "Search...",
label = "Search",
className,
ref,
onKeyDown,
}: Props) {
return (
<div className={cn("relative", className)}>
<Search
aria-hidden
className="pointer-events-none absolute left-2 top-1/2 size-3.5 -translate-y-1/2 text-muted-foreground"
/>
<Input
ref={ref}
aria-label={label}
placeholder={placeholder}
value={value}
onChange={(e) => onChange(e.target.value)}
onKeyDown={onKeyDown}
className={cn(COMPACT_CONTROL, "pl-7 text-xs")}
/>
</div>
);
}
6 changes: 6 additions & 0 deletions src/components/controls/controlHeight.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
// The shared height for a dense toolbar control, so an input, a filter popover
// trigger, and an icon button sitting in the same header line up, and every
// compact header row ends up the same height as the window toolbar. Change it
// here to restyle every dense control at once.
export const COMPACT_CONTROL = "h-7";
export const COMPACT_CONTROL_ICON = "size-7";
148 changes: 148 additions & 0 deletions src/components/tasks/TaskDetailPanel.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
import { useCallback, useEffect, useState } from "react";
import { readTextFile, writeTextFile } from "@tauri-apps/plugin-fs";
import { Maximize2, X } from "lucide-react";

import { Button } from "@/components/ui/button";
import { MarkdownViewer } from "@/components/viewer/MarkdownViewer";
import { TaskHeader } from "@/components/document/TaskHeader";
import { parseFrontmatter } from "@/lib/scan";
import { toggleTaskCheckbox } from "@/lib/checklist";
import type { ViewSettings } from "@/lib/storage";

interface Props {
path: string;
rootPath: string | undefined;
viewSettings: ViewSettings;
// Bumped when the workspace is refreshed or a task is written, so an open
// detail re-reads rather than showing a stale body.
reloadSignal: number;
onOpenFull: (path: string) => void;
onNavigate: (absolutePath: string) => void;
// A checkbox toggle wrote the file; let the list refresh its progress.
onChanged: () => void;
onClose: () => void;
}

interface Loaded {
meta: Record<string, unknown>;
body: string;
title: string;
}

function basenameTitle(path: string): string {
const name = path.split(/[\\/]/).pop() ?? path;
return name.replace(/\.mdx?$/i, "");
}

export function TaskDetailPanel({
path,
rootPath,
viewSettings,
reloadSignal,
onOpenFull,
onNavigate,
onChanged,
onClose,
}: Props) {
const [loaded, setLoaded] = useState<Loaded | undefined>();
const [error, setError] = useState<string | undefined>();
const [loading, setLoading] = useState(false);
const [reloadKey, setReloadKey] = useState(0);

useEffect(() => {
let cancelled = false;
setLoading(true);
void (async () => {
try {
const { data, content } = parseFrontmatter(await readTextFile(path));
if (cancelled) return;
const title =
typeof data.title === "string" && data.title.trim() ? data.title : basenameTitle(path);
setLoaded({ meta: data, body: content, title });
setError(undefined);
} catch (e) {
if (!cancelled) {
setLoaded(undefined);
setError(String(e));
}
} finally {
if (!cancelled) setLoading(false);
}
})();
return () => {
cancelled = true;
};
}, [path, reloadSignal, reloadKey]);

const handleToggle = useCallback(
async (index: number) => {
try {
const next = toggleTaskCheckbox(await readTextFile(path), index);
if (next === null) return;
await writeTextFile(path, next);
setReloadKey((k) => k + 1);
onChanged();
} catch (e) {
setError(String(e));
}
},
[path, onChanged]
);

return (
<div className="flex h-full min-h-0 flex-col" data-slot="task-detail">
<div className="flex shrink-0 items-start justify-between gap-3 border-b px-4 py-3">
<div className="min-w-0">
<h2 className="truncate text-base font-semibold">
{loaded?.title ?? basenameTitle(path)}
</h2>
{loaded && <TaskHeader meta={loaded.meta} relPath={path} content={loaded.body} />}
</div>
<div className="flex shrink-0 items-center gap-1">
<Button
type="button"
size="sm"
variant="ghost"
className="gap-1.5 text-muted-foreground"
onClick={() => onOpenFull(path)}
title="Open full"
aria-label="Open full"
>
<Maximize2 className="size-3.5" />
Open
</Button>
<Button
type="button"
size="icon"
variant="ghost"
className="size-7 text-muted-foreground"
onClick={onClose}
title="Hide details"
aria-label="Hide details"
>
<X className="size-4" />
</Button>
</div>
</div>
<div className="min-h-0 flex-1 overflow-auto px-4 py-4">
{loading && !loaded ? (
<p className="text-sm text-muted-foreground">Loading…</p>
) : error ? (
<p className="text-sm text-destructive">{error}</p>
) : loaded ? (
<MarkdownViewer
content={loaded.body}
fontFamily={viewSettings.fontFamily}
fontSize={viewSettings.fontSize}
codeThemeLight={viewSettings.codeThemeLight}
codeThemeDark={viewSettings.codeThemeDark}
currentFilePath={path}
rootPath={rootPath}
onNavigate={onNavigate}
onToggleTask={handleToggle}
/>
) : null}
</div>
</div>
);
}
4 changes: 2 additions & 2 deletions src/components/tasks/TaskListGroup.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ export function TaskListGroup({
aria-expanded={!collapsed}
aria-controls={listId}
aria-label={groupLabel(status, tasks.length)}
className="flex items-center gap-2 px-3 py-1.5 text-left hover:bg-accent/50 focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50"
className="flex items-center gap-2 px-3 py-1 text-left hover:bg-accent/50 focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50"
>
<ChevronRight
aria-hidden
Expand All @@ -58,7 +58,7 @@ export function TaskListGroup({
{!collapsed && (
<div id={listId}>
<TaskListColumnHeader />
<ul className="flex flex-col">
<ul className="flex flex-col divide-y divide-border/70">
{tasks.map((task) => (
<li key={task.path}>
<TaskListRow
Expand Down
2 changes: 1 addition & 1 deletion src/components/tasks/TaskListRow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ export function TaskListRow({
data-slot="task-row"
className={cn(
TASK_LIST_COLUMNS,
"w-full py-1.5 text-left text-[13px] transition-colors",
"w-full py-1 text-left text-xs transition-colors",
sidebarRowState(selected),
advancing && "pointer-events-none opacity-50"
)}
Expand Down
81 changes: 81 additions & 0 deletions src/components/tasks/TasksTabContent.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,9 +27,11 @@ vi.mock("@tauri-apps/api/core", () => ({ invoke: vi.fn() }));
vi.mock("@tauri-apps/plugin-fs", () => ({
watch: vi.fn(async () => () => {}),
readTextFile: vi.fn(async () => ""),
writeTextFile: vi.fn(async () => {}),
}));

import { invoke } from "@tauri-apps/api/core";
import { readTextFile, writeTextFile } from "@tauri-apps/plugin-fs";

const ROOT = "/ws";

Expand Down Expand Up @@ -116,6 +118,8 @@ beforeEach(() => {
storeSet.mockReset();
storeSave.mockReset();
storeGet.mockResolvedValue(undefined);
vi.mocked(readTextFile).mockReset();
vi.mocked(readTextFile).mockResolvedValue("");
});

function listOnly(tasks: Task[]) {
Expand Down Expand Up @@ -206,10 +210,87 @@ describe("the tasks tab", () => {
expect(lens()?.className).toContain("min-h-0");
unmount();

// Before a task is picked the list fills the area on its own; no detail pane.
render(<TasksTabContent {...props("list")} />);
await waitFor(() => expect(screen.getByText("Title task-1")).toBeTruthy());
expect(lens()?.className).toContain("flex-1");
expect(lens()?.className).toContain("min-h-0");
expect(document.querySelector('[data-slot="task-detail"]')).toBeNull();
});

it("opens a clicked list task in the detail pane, not a new page", async () => {
vi.mocked(readTextFile).mockResolvedValue(
"---\nid: task-1\nstatus: To Do\ntitle: Rotate keys\n---\n\nThe body."
);
listOnly([task("task-1", "To Do")]);

render(<TasksTabContent {...props("list")} />);
await waitFor(() => expect(screen.getByText("Title task-1")).toBeTruthy());
expect(document.querySelector('[data-slot="task-detail"]')).toBeNull();

fireEvent.click(screen.getByText("Title task-1"));

await waitFor(() =>
expect(document.querySelector('[data-slot="task-detail"]')).toBeTruthy()
);
expect(openInActive).not.toHaveBeenCalled();
});

it("closes the detail pane and hands the width back to the list", async () => {
vi.mocked(readTextFile).mockResolvedValue("---\nid: task-1\nstatus: To Do\n---\n");
listOnly([task("task-1", "To Do")]);

render(<TasksTabContent {...props("list")} />);
await waitFor(() => expect(screen.getByText("Title task-1")).toBeTruthy());
fireEvent.click(screen.getByText("Title task-1"));
await waitFor(() =>
expect(document.querySelector('[data-slot="task-detail"]')).toBeTruthy()
);

fireEvent.click(screen.getByRole("button", { name: "Hide details" }));

await waitFor(() =>
expect(document.querySelector('[data-slot="task-detail"]')).toBeNull()
);
expect(lens()?.className).toContain("flex-1");
});

it("opens the task full-window from the detail's Open control", async () => {
vi.mocked(readTextFile).mockResolvedValue("---\nid: task-1\nstatus: To Do\n---\n");
listOnly([task("task-1", "To Do")]);

render(<TasksTabContent {...props("list")} />);
await waitFor(() => expect(screen.getByText("Title task-1")).toBeTruthy());
fireEvent.click(screen.getByText("Title task-1"));
await waitFor(() =>
expect(document.querySelector('[data-slot="task-detail"]')).toBeTruthy()
);

fireEvent.click(screen.getByRole("button", { name: "Open full" }));
expect(openInActive).toHaveBeenCalledWith(fileTarget("/ws/tasks/task-1.md"));
});

it("writes back when a criteria checkbox is toggled in the detail", async () => {
vi.mocked(writeTextFile).mockClear();
vi.mocked(readTextFile).mockResolvedValue(
"---\nid: task-1\nstatus: To Do\n---\n\n## Acceptance Criteria\n\n- [ ] First\n- [x] Second\n"
);
listOnly([task("task-1", "To Do")]);

render(<TasksTabContent {...props("list")} />);
await waitFor(() => expect(screen.getByText("Title task-1")).toBeTruthy());
fireEvent.click(screen.getByText("Title task-1"));
await waitFor(() =>
expect(document.querySelector('[data-slot="task-detail"]')).toBeTruthy()
);

const boxes = document.querySelectorAll<HTMLInputElement>(
'[data-slot="task-detail"] input[type="checkbox"]'
);
expect(boxes.length).toBe(2);
fireEvent.click(boxes[0]);

await waitFor(() => expect(writeTextFile).toHaveBeenCalled());
});

it("carries no view switch of its own: the toolbar owns that", async () => {
Expand Down
Loading
Loading