From acdaa9b52e748865c6c357f9fe046131ee9faae0 Mon Sep 17 00:00:00 2001 From: Ali Turki Date: Thu, 23 Jul 2026 14:12:22 +0800 Subject: [PATCH] feat(tasks): open a clicked list task in a detail pane beside the list In the full-window list, clicking a task now opens its details in a resizable pane on the right instead of navigating away. The list stays on the left; a draggable divider splits them, and a close control hands the width back to the list. The pane shows the status, priority, assignee and progress header plus the rendered description and acceptance criteria; ticking a criterion writes back through the same core the reader uses, and Open takes the task full-window. The board is unchanged: it already fills the width, so a row there still opens. Whether a view selects into a detail pane or opens is a flag on the view definition rather than a check against the view name. Also tightens the list: a hairline between rows, denser row and group padding, and slightly smaller row text. The full-window tasks header is compact too, built on a shared compact-control height and a reusable search field so a dense header and its input line up and can be restyled in one place. --- src/components/controls/SearchField.tsx | 46 ++++++ src/components/controls/controlHeight.ts | 6 + src/components/tasks/TaskDetailPanel.tsx | 148 ++++++++++++++++++ src/components/tasks/TaskListGroup.tsx | 4 +- src/components/tasks/TaskListRow.tsx | 2 +- src/components/tasks/TasksTabContent.test.tsx | 81 ++++++++++ src/components/tasks/TasksTabContent.tsx | 99 ++++++++---- src/components/tasks/taskViews.ts | 7 +- 8 files changed, 354 insertions(+), 39 deletions(-) create mode 100644 src/components/controls/SearchField.tsx create mode 100644 src/components/controls/controlHeight.ts create mode 100644 src/components/tasks/TaskDetailPanel.tsx diff --git a/src/components/controls/SearchField.tsx b/src/components/controls/SearchField.tsx new file mode 100644 index 0000000..33c7660 --- /dev/null +++ b/src/components/controls/SearchField.tsx @@ -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; + onKeyDown?: KeyboardEventHandler; +} + +// 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 ( +
+ + onChange(e.target.value)} + onKeyDown={onKeyDown} + className={cn(COMPACT_CONTROL, "pl-7 text-xs")} + /> +
+ ); +} diff --git a/src/components/controls/controlHeight.ts b/src/components/controls/controlHeight.ts new file mode 100644 index 0000000..521d0c5 --- /dev/null +++ b/src/components/controls/controlHeight.ts @@ -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"; diff --git a/src/components/tasks/TaskDetailPanel.tsx b/src/components/tasks/TaskDetailPanel.tsx new file mode 100644 index 0000000..00c549b --- /dev/null +++ b/src/components/tasks/TaskDetailPanel.tsx @@ -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; + 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(); + const [error, setError] = useState(); + 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 ( +
+
+
+

+ {loaded?.title ?? basenameTitle(path)} +

+ {loaded && } +
+
+ + +
+
+
+ {loading && !loaded ? ( +

Loading…

+ ) : error ? ( +

{error}

+ ) : loaded ? ( + + ) : null} +
+
+ ); +} diff --git a/src/components/tasks/TaskListGroup.tsx b/src/components/tasks/TaskListGroup.tsx index ed10c79..240a171 100644 --- a/src/components/tasks/TaskListGroup.tsx +++ b/src/components/tasks/TaskListGroup.tsx @@ -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" > -
    +
      {tasks.map((task) => (
    • ({ 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"; @@ -116,6 +118,8 @@ beforeEach(() => { storeSet.mockReset(); storeSave.mockReset(); storeGet.mockResolvedValue(undefined); + vi.mocked(readTextFile).mockReset(); + vi.mocked(readTextFile).mockResolvedValue(""); }); function listOnly(tasks: Task[]) { @@ -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(); 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(); + 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(); + 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(); + 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(); + 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( + '[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 () => { diff --git a/src/components/tasks/TasksTabContent.tsx b/src/components/tasks/TasksTabContent.tsx index 459e207..2c430e9 100644 --- a/src/components/tasks/TasksTabContent.tsx +++ b/src/components/tasks/TasksTabContent.tsx @@ -1,9 +1,15 @@ import { useCallback, useState } from "react"; -import { RefreshCw, Search } from "lucide-react"; +import { RefreshCw } from "lucide-react"; import { cn } from "@/lib/utils"; import { Button } from "@/components/ui/button"; -import { Input } from "@/components/ui/input"; +import { SearchField } from "@/components/controls/SearchField"; +import { COMPACT_CONTROL_ICON } from "@/components/controls/controlHeight"; +import { + ResizableHandle, + ResizablePanel, + ResizablePanelGroup, +} from "@/components/ui/resizable"; import { fileTarget } from "@/lib/tabKinds"; import { taskCountLabel } from "@/lib/taskFilter"; import { @@ -13,6 +19,7 @@ import { import { TaskFilterPopover } from "@/components/explorer/TaskFilterPopover"; import type { TabContentProps } from "@/components/document/tabKinds"; import { TasksLens } from "./TasksLens"; +import { TaskDetailPanel } from "./TaskDetailPanel"; import { TASK_VIEWS } from "./taskViews"; export function TasksTabContent({ @@ -24,9 +31,10 @@ export function TasksTabContent({ }: TabContentProps) { const [query, setQuery] = useState(""); const [refreshSignal, setRefreshSignal] = useState(0); + const [selectedPath, setSelectedPath] = useState(undefined); const { openInActive, openInNew } = pane; - const open = useCallback( + const navigate = useCallback( (path: string) => openInActive(fileTarget(path)), [openInActive] ); @@ -34,6 +42,28 @@ export function TasksTabContent({ (path: string) => openInNew(fileTarget(path)), [openInNew] ); + const refresh = useCallback(() => setRefreshSignal((n) => n + 1), []); + + const viewDef = TASK_VIEWS[viewSettings.taskTabView]; + const withDetail = viewDef.detail; + // The pane appears once a task is picked and the close control puts it away, + // handing the width back to the list. + const detailShown = withDetail && selectedPath !== undefined; + + // In a detail view a row selects into the pane beside it; otherwise it opens. + const lens = ( + + ); return (
      - setRefreshSignal((n) => n + 1)} - /> - + + {withDetail && selectedPath !== undefined ? ( + + + {lens} + + + + setSelectedPath(undefined)} + /> + + + ) : ( + lens + )}
      ); @@ -72,32 +109,26 @@ function TasksHeader({ query, onQueryChange, onRefresh }: HeaderProps) { const { count } = useTaskFilter(); return ( -
      +

      Tasks

      {count && ( {taskCountLabel(count.shown, count.total)} )} -
      - - onQueryChange(e.target.value)} - aria-label="Search tasks" - placeholder="Search tasks..." - className="h-7 pl-7 text-xs" - /> -
      +