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
30 changes: 27 additions & 3 deletions apps/desktop/src-tauri/src/db/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
//! together against a migrated in-memory database, the same shape the commands
//! compose at runtime.

use reflect_index_schema::LATEST_SCHEMA_VERSION;
use rusqlite::Connection;
use serde_json::Value;

Expand Down Expand Up @@ -68,6 +69,7 @@ fn task(marker_offset: i64, text: &str, checked: bool) -> IndexedTask {
marker_offset,
text: text.to_string(),
raw: format!("[{}] {text}", if checked { "x" } else { " " }),
breadcrumbs: vec![],
checked,
due_date: None,
}
Expand All @@ -81,7 +83,7 @@ fn migrations_are_valid_and_idempotent() {
let version: i64 = conn
.query_row("PRAGMA user_version", [], |row| row.get(0))
.unwrap();
assert_eq!(version, 16); // applied migrations (0001 through 0016)
assert_eq!(version, LATEST_SCHEMA_VERSION as i64);
migrate(&mut conn).expect("re-running to_latest is a no-op");
}

Expand Down Expand Up @@ -594,13 +596,14 @@ fn apply_note_inserts_tasks_and_replace_clears_them() {

let rows = run_query(
&conn,
"SELECT marker_offset, text, checked, due_date FROM tasks WHERE note_path = 'notes/a.md' ORDER BY marker_offset",
"SELECT marker_offset, text, breadcrumbs, checked, due_date FROM tasks WHERE note_path = 'notes/a.md' ORDER BY marker_offset",
&[],
)
.unwrap();
assert_eq!(rows.len(), 2);
assert_eq!(rows[0]["marker_offset"], Value::from(4));
assert_eq!(rows[0]["text"], Value::from("buy milk"));
assert_eq!(rows[0]["breadcrumbs"], Value::from("[]"));
assert_eq!(rows[0]["checked"], Value::from(0));
assert_eq!(rows[0]["due_date"], Value::from("2026-07-01"));
assert_eq!(rows[1]["checked"], Value::from(1));
Expand All @@ -612,6 +615,27 @@ fn apply_note_inserts_tasks_and_replace_clears_them() {
assert_eq!(after[0]["n"], Value::from(0));
}

#[test]
fn apply_note_serializes_task_breadcrumbs() {
let conn = migrated();
let mut seeded = note("notes/a.md", "A", vec![]);
let mut with_context = task(4, "ship it", false);
with_context.breadcrumbs = vec!["Project".to_string(), "Phase one".to_string()];
seeded.tasks = vec![with_context];
apply_note(&conn, &seeded).unwrap();

let rows = run_query(
&conn,
"SELECT breadcrumbs FROM tasks WHERE note_path = 'notes/a.md'",
&[],
)
.unwrap();
assert_eq!(
rows[0]["breadcrumbs"],
Value::from("[\"Project\",\"Phase one\"]")
);
}

#[test]
fn open_tasks_read_includes_private_notes_and_excludes_completed() {
// The semantics `getOpenTasks` (queries.ts) relies on: open checkboxes joined
Expand Down Expand Up @@ -801,7 +825,7 @@ fn open_index_at_creates_migrates_and_reopens() {
let version: i64 = conn
.query_row("PRAGMA user_version", [], |row| row.get(0))
.unwrap();
assert_eq!(version, 16);
assert_eq!(version, LATEST_SCHEMA_VERSION as i64);
let journal: String = conn
.query_row("PRAGMA journal_mode", [], |row| row.get(0))
.unwrap();
Expand Down
9 changes: 8 additions & 1 deletion apps/desktop/src-tauri/src/db/write.rs
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,9 @@ pub(super) struct IndexedEmail {
pub(super) struct IndexedTask {
pub(super) marker_offset: i64,
pub(super) text: String,
/// Parent outline/list item text, top-down, displayed in the Tasks view.
#[serde(default)]
pub(super) breadcrumbs: Vec<String>,
pub(super) raw: String,
pub(super) checked: bool,
/// Explicit due date (first `[[YYYY-MM-DD]]` in the item), or None.
Expand Down Expand Up @@ -178,13 +181,17 @@ pub(super) fn apply_note(conn: &Connection, note: &IndexedNote) -> AppResult<()>
}
{
let mut stmt = conn.prepare_cached(
"INSERT INTO tasks(note_path, marker_offset, text, raw, checked, due_date) VALUES(?1, ?2, ?3, ?4, ?5, ?6)",
"INSERT INTO tasks(note_path, marker_offset, text, breadcrumbs, raw, checked, due_date) VALUES(?1, ?2, ?3, ?4, ?5, ?6, ?7)",
)?;
for task in &note.tasks {
let breadcrumbs = serde_json::to_string(&task.breadcrumbs).map_err(|err| {
crate::error::AppError::io(format!("serialize task breadcrumbs: {err}"))
})?;
stmt.execute(params![
note.path,
task.marker_offset,
task.text,
breadcrumbs,
task.raw,
i64::from(task.checked),
task.due_date
Expand Down
33 changes: 33 additions & 0 deletions apps/desktop/src/components/tasks/task-breadcrumbs.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import type { ReactElement } from 'react'
import { visibleTaskBreadcrumbs } from '@reflect/core'

interface TaskBreadcrumbsProps {
breadcrumbs: readonly string[]
/** Select every task row sharing this outline context. */
onSelect: () => void
}

/** One V1-style outline-context row above the consecutive tasks it labels. */
export function TaskBreadcrumbs({
breadcrumbs,
onSelect,
}: TaskBreadcrumbsProps): ReactElement | null {
const visible = visibleTaskBreadcrumbs(breadcrumbs)
if (visible.length === 0) {
return null
}
const label = visible.join(' → ')

return (
<li className="min-w-0 px-4 pt-1.5 lg:px-12">
<button
type="button"
title={label}
onClick={onSelect}
className="block max-w-full truncate text-left text-xs leading-5 text-text-muted transition-colors hover:text-text focus-visible:text-text focus-visible:outline-none"
>
{label}
</button>
</li>
)
}
51 changes: 32 additions & 19 deletions apps/desktop/src/components/tasks/task-group-section.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,13 @@
import type { MutableRefObject, ReactElement } from 'react'
import { Fragment, type MutableRefObject, type ReactElement } from 'react'
import { Plus } from 'lucide-react'
import type { OpenTask, TaskGroup } from '@reflect/core'
import { groupTaskContexts, type OpenTask, type TaskGroup } from '@reflect/core'
import { addTargetForGroup, taskGroupHeaderStyle } from '@/lib/tasks/task-group-presentation'
import type { InsertTaskTarget } from '@/lib/tasks/task-insert-target'
import { taskKey } from '@/lib/tasks/task-identity'
import type { TaskSelection } from '@/lib/tasks/use-task-selection'
import type { TaskRowEditHandlers } from '@/lib/tasks/use-task-row-handlers'
import { cn } from '@/lib/utils'
import { TaskBreadcrumbs } from './task-breadcrumbs'
import { TaskRow } from './task-row'

interface TaskGroupSectionProps {
Expand Down Expand Up @@ -48,6 +49,7 @@ export function TaskGroupSection({
const { notePath } = group
const { icon, colorClass } = taskGroupHeaderStyle(group)
const addTarget = addTargetForGroup(group, today)
const contexts = groupTaskContexts(group.tasks)

return (
<section>
Expand Down Expand Up @@ -82,24 +84,35 @@ export function TaskGroupSection({
{group.tasks.length === 0 ? (
<li className="px-4 py-1.5 text-sm text-text-muted lg:px-12">No tasks</li>
) : (
group.tasks.map((task) => {
const key = taskKey(task)
const selected = selection.isSelected(key)
contexts.map((context) => {
const firstTask = context.tasks[0]!
return (
<TaskRow
key={key}
task={task}
showSource={showSource}
selected={selected}
editing={selection.isSoleSelected(key)}
taskActionPending={taskActionPending}
togglesSelection={selected && selection.selectedCount > 1}
onSelect={(event) => selection.clickSelect(key, event)}
onSelectionCheckboxToggle={() => onSelectionCheckboxToggle(task)}
{...editHandlers(task)}
convertControllerRef={convertControllerRef}
onOpen={onOpen}
/>
<Fragment key={taskKey(firstTask)}>
<TaskBreadcrumbs
breadcrumbs={context.breadcrumbs}
onSelect={() => selection.select(context.tasks.map(taskKey))}
/>
{context.tasks.map((task) => {
const key = taskKey(task)
const selected = selection.isSelected(key)
return (
<TaskRow
key={key}
task={task}
showSource={showSource}
selected={selected}
editing={selection.isSoleSelected(key)}
taskActionPending={taskActionPending}
togglesSelection={selected && selection.selectedCount > 1}
onSelect={(event) => selection.clickSelect(key, event)}
onSelectionCheckboxToggle={() => onSelectionCheckboxToggle(task)}
{...editHandlers(task)}
convertControllerRef={convertControllerRef}
onOpen={onOpen}
/>
)
})}
</Fragment>
)
})
)}
Expand Down
55 changes: 55 additions & 0 deletions apps/desktop/src/components/tasks/tasks-screen.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -188,6 +188,7 @@ function task(overrides: Partial<OpenTask> = {}): OpenTask {
raw: `[ ] ${text}`,
checked: false,
text,
breadcrumbs: [],
noteTitle: 'N',
dueDate: null,
dailyDate: null,
Expand Down Expand Up @@ -324,6 +325,60 @@ describe('TasksScreen', () => {
view.unmount()
})

it('renders one breadcrumb per consecutive task context and selects that context', async () => {
getOpenTasks.mockResolvedValue([
task({
notePath: 'notes/p.md',
markerOffset: 2,
text: 'first',
noteTitle: 'Project',
breadcrumbs: ['StartupToolbox', 'Reflections'],
}),
task({
notePath: 'notes/p.md',
markerOffset: 20,
text: 'second',
noteTitle: 'Project',
breadcrumbs: ['StartupToolbox', 'Reflections'],
}),
task({
notePath: 'notes/p.md',
markerOffset: 40,
text: 'third',
noteTitle: 'Project',
breadcrumbs: ['StartupToolbox', 'Later'],
}),
])
const view = renderScreen()

const context = await view.findByRole('button', {
name: 'StartupToolbox → Reflections',
})
expect(view.getAllByText('StartupToolbox → Reflections')).toHaveLength(1)
view.getByText('StartupToolbox → Later')

await userEvent.click(context)
expect(view.getByRole('button', { name: 'Convert to bullet 2' })).toBeDefined()
view.unmount()
})

it('hides a lone generic task breadcrumb', async () => {
getOpenTasks.mockResolvedValue([
task({
notePath: 'notes/p.md',
markerOffset: 2,
text: 'project task',
noteTitle: 'Project',
breadcrumbs: ['Tasks:'],
}),
])
const view = renderScreen()

await view.findByText('project task')
expect(view.queryByText('Tasks:')).toBeNull()
view.unmount()
})

it('opens a task’s source note via the open arrow', async () => {
getOpenTasks.mockResolvedValue([
task({ notePath: 'notes/p.md', dailyDate: null, text: 'project task', noteTitle: 'Project' }),
Expand Down
15 changes: 12 additions & 3 deletions apps/desktop/src/dev/dev-index-db.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,16 @@ function sampleNote(overrides: Partial<IndexedNote> = {}): IndexedNote {
aliases: [],
emails: [{ email: 'Sample@Example.com', emailKey: 'sample@example.com' }],
assets: [],
tasks: [{ markerOffset: 40, text: 'Do the thing', raw: '- [ ] Do the thing', checked: false, dueDate: null }],
tasks: [
{
markerOffset: 40,
text: 'Do the thing',
breadcrumbs: ['Project'],
raw: '- [ ] Do the thing',
checked: false,
dueDate: null,
},
],
...overrides,
}
}
Expand Down Expand Up @@ -64,8 +73,8 @@ describe('createDevIndexDb', () => {
const tags = db.query('SELECT tag FROM tags WHERE note_path = ?', ['notes/sample.md'])
expect(tags).toEqual([{ tag: 'book' }])

const tasks = db.query('SELECT text, checked FROM tasks', [])
expect(tasks).toEqual([{ text: 'Do the thing', checked: 0 }])
const tasks = db.query('SELECT text, breadcrumbs, checked FROM tasks', [])
expect(tasks).toEqual([{ text: 'Do the thing', breadcrumbs: '["Project"]', checked: 0 }])

const emails = db.query('SELECT email, email_key FROM note_emails', [])
expect(emails).toEqual([
Expand Down
12 changes: 10 additions & 2 deletions apps/desktop/src/dev/dev-index-db.ts
Original file line number Diff line number Diff line change
Expand Up @@ -168,8 +168,16 @@ export async function createDevIndexDb(): Promise<DevIndexDb> {
for (const task of note.tasks) {
run(
db,
'INSERT INTO tasks(note_path, marker_offset, text, raw, checked, due_date) VALUES(?, ?, ?, ?, ?, ?)',
[note.path, task.markerOffset, task.text, task.raw, task.checked, task.dueDate],
'INSERT INTO tasks(note_path, marker_offset, text, breadcrumbs, raw, checked, due_date) VALUES(?, ?, ?, ?, ?, ?, ?)',
[
note.path,
task.markerOffset,
task.text,
JSON.stringify(task.breadcrumbs),
task.raw,
task.checked,
task.dueDate,
],
)
}
const searchBody = note.assetText === '' ? note.text : `${note.text}\n${note.assetText}`
Expand Down
12 changes: 12 additions & 0 deletions apps/desktop/src/lib/selection/use-list-selection.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,18 @@ describe('useListSelection', () => {
expect(result.current.activeKey()).toBe('c')
})

it('programmatically selects visible keys in render order', () => {
const { result } = renderHook(() => useListSelection(KEYS))

act(() => result.current.select(['d', 'missing', 'b']))
expect([...result.current.selected]).toEqual(['b', 'd'])
expect(result.current.activeKey()).toBe('d')

act(() => result.current.select([]))
expect(result.current.selectedCount).toBe(0)
expect(result.current.activeKey()).toBeNull()
})

it('prunes keys that leave the visible order', () => {
const { result, rerender } = renderHook(({ keys }) => useListSelection(keys), {
initialProps: { keys: KEYS },
Expand Down
11 changes: 11 additions & 0 deletions apps/desktop/src/lib/selection/use-list-selection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@ export interface ListSelection {
isSoleSelected: (key: string) => boolean
/** Apply a row click, honoring ⌘/Ctrl (toggle) and Shift (range) modifiers. */
clickSelect: (key: string, event: Pick<MouseEvent, 'metaKey' | 'ctrlKey' | 'shiftKey'>) => void
/** Replace the selection with these visible keys, normalized to render order. */
select: (keys: readonly string[]) => void
selectAll: () => void
clear: () => void
/** ↑/↓: move a single selection by one in the flat order. */
Expand Down Expand Up @@ -115,6 +117,14 @@ export function useListSelection(orderedKeys: readonly string[]): ListSelection
[rangeBetween],
)

const select = useCallback((keys: readonly string[]) => {
const requested = new Set(keys)
const visible = orderRef.current.filter((key) => requested.has(key))
setSelected(new Set(visible))
anchorRef.current = visible[0] ?? null
cursorRef.current = visible.at(-1) ?? null
}, [])

const selectAll = useCallback(() => {
const order = orderRef.current
if (order.length === 0) {
Expand Down Expand Up @@ -175,6 +185,7 @@ export function useListSelection(orderedKeys: readonly string[]): ListSelection
isSelected: (key) => selected.has(key),
isSoleSelected: (key) => selected.size === 1 && selected.has(key),
clickSelect,
select,
selectAll,
clear,
move,
Expand Down
1 change: 1 addition & 0 deletions apps/desktop/src/lib/tasks/recently-completed.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ function task(over: Partial<OpenTask> = {}): OpenTask {
raw: '[ ] do it',
checked: false,
text: 'do it',
breadcrumbs: [],
noteTitle: 'N',
dueDate: null,
dailyDate: null,
Expand Down
Loading
Loading