tuiboard 0.9.2 → 0.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +19 -0
- package/README.md +3 -0
- package/package.json +1 -1
- package/src/notes/index.ts +144 -0
- package/src/parser/markdown.ts +51 -0
- package/src/store/index.ts +53 -0
- package/src/types.ts +14 -0
- package/src/ui/Modal.tsx +44 -1
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,24 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.10.0] - 2026-09-09
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
- **A task's note is readable inside tuiboard.** When a task's title is a link —
|
|
14
|
+
`[[Nome nota|Titolo]]` or `[Titolo](Tasks/Nome.md)` — the detail view (`o`)
|
|
15
|
+
shows that note's text, scrollable, instead of pointing at Obsidian. Both link
|
|
16
|
+
forms work, so the convention stands on its own: a board written in plain
|
|
17
|
+
markdown gets the feature too. Links *inside* a sentence stay mentions and are
|
|
18
|
+
listed as before — a task that mentions a person is not documented by that
|
|
19
|
+
person's page. A missing note says which name it looked for; two notes sharing
|
|
20
|
+
a name resolve to the nearest and the shadowed one is named.
|
|
21
|
+
|
|
22
|
+
### Fixed
|
|
23
|
+
- **Markdown links no longer show as raw syntax in task titles.** `displayTitle`
|
|
24
|
+
stripped wikilinks but not `[text](path.md)`, so a board written without
|
|
25
|
+
Obsidian showed the whole link as its title — in the board, the planner and
|
|
26
|
+
the bar widget.
|
|
27
|
+
|
|
10
28
|
## [0.9.2] - 2026-09-08
|
|
11
29
|
|
|
12
30
|
### Fixed
|
|
@@ -298,6 +316,7 @@ First public release on npm. This entry captures the full feature set at launch.
|
|
|
298
316
|
|
|
299
317
|
Built with [OpenTUI](https://opentui.com) + SolidJS on Bun.
|
|
300
318
|
|
|
319
|
+
[0.10.0]: https://github.com/NazzarenoGiannelli/tuiboard/releases/tag/v0.10.0
|
|
301
320
|
[0.9.2]: https://github.com/NazzarenoGiannelli/tuiboard/releases/tag/v0.9.2
|
|
302
321
|
[0.9.1]: https://github.com/NazzarenoGiannelli/tuiboard/releases/tag/v0.9.1
|
|
303
322
|
[0.9.0]: https://github.com/NazzarenoGiannelli/tuiboard/releases/tag/v0.9.0
|
package/README.md
CHANGED
|
@@ -534,6 +534,9 @@ clobber an edit made in the TUI or another editor in the meantime.
|
|
|
534
534
|
|
|
535
535
|
See [CHANGELOG.md](CHANGELOG.md) for the full release history.
|
|
536
536
|
|
|
537
|
+
- **v0.10** — a task's note, read inside tuiboard: when a task's title is a
|
|
538
|
+
link, `o` shows that note's text instead of pointing at Obsidian. Works with
|
|
539
|
+
plain markdown links too, so the convention needs no vault.
|
|
537
540
|
- **v0.9** — tuiboard makes its own boards: a `+` that creates or adopts them,
|
|
538
541
|
onboarding on first run instead of an error, `tuiboard board` headless, and
|
|
539
542
|
single-pane mode so a narrow vertical panel shows one zone at a time instead
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "tuiboard",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.10.0",
|
|
4
4
|
"description": "Terminal kanban for markdown task boards, with optional Today/Tomorrow planner, 24h agenda + calendar overlay, and a live Claude Code agent view. Use only the panels you want.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Task notes: finding the file a task's link points at, and reading it.
|
|
3
|
+
*
|
|
4
|
+
* The convention is tuiboard's own, not Obsidian's: a task's note is the link
|
|
5
|
+
* that wraps its title, written either way —
|
|
6
|
+
*
|
|
7
|
+
* - [ ] [[Nome nota|Titolo mostrato]] wiki style
|
|
8
|
+
* - [ ] [Titolo mostrato](Tasks/Nome.md) plain markdown
|
|
9
|
+
*
|
|
10
|
+
* The second form is how any markdown document links another, so a board that
|
|
11
|
+
* has never heard of wikilinks gets the feature too. The parser decides which
|
|
12
|
+
* link qualifies (see `titleNoteLink`); this module only turns it into a file.
|
|
13
|
+
*
|
|
14
|
+
* Pure except for the filesystem reads it exists to perform: no store, no
|
|
15
|
+
* renderer, testable against a temp directory.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { readFileSync, readdirSync, statSync } from "node:fs";
|
|
19
|
+
import { basename, dirname, extname, join, resolve, sep } from "node:path";
|
|
20
|
+
|
|
21
|
+
import type { TaskNoteLink } from "~/types";
|
|
22
|
+
|
|
23
|
+
/** Directories never worth walking for notes. */
|
|
24
|
+
const SKIP_DIRS = new Set([".git", ".obsidian", "node_modules", ".trash", ".stversions"]);
|
|
25
|
+
|
|
26
|
+
/** How deep the scan goes. Deeper than any sane note layout, shallow enough to end. */
|
|
27
|
+
const MAX_DEPTH = 8;
|
|
28
|
+
|
|
29
|
+
/** Note name (lowercased, without extension) → every file carrying that name. */
|
|
30
|
+
export type NoteIndex = Record<string, string[]>;
|
|
31
|
+
|
|
32
|
+
export interface ResolvedNote {
|
|
33
|
+
path: string;
|
|
34
|
+
/** Same-named files that lost to the winner, nearest-first. Absent when unique. */
|
|
35
|
+
shadowed?: string[];
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface MissingNote {
|
|
39
|
+
missing: string;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Index every markdown file under `root`, by name.
|
|
44
|
+
*
|
|
45
|
+
* Obsidian resolves `[[Name]]` by name rather than by path, and notes written
|
|
46
|
+
* by hand rely on that, so tuiboard does the same instead of demanding paths.
|
|
47
|
+
* A missing or unreadable directory yields an empty index rather than an
|
|
48
|
+
* error: a broken note link must never stop the board from opening.
|
|
49
|
+
*/
|
|
50
|
+
export function buildNoteIndex(root: string): NoteIndex {
|
|
51
|
+
const index: NoteIndex = {};
|
|
52
|
+
|
|
53
|
+
const walk = (dir: string, depth: number): void => {
|
|
54
|
+
if (depth > MAX_DEPTH) return;
|
|
55
|
+
let entries: string[];
|
|
56
|
+
try {
|
|
57
|
+
entries = readdirSync(dir);
|
|
58
|
+
} catch {
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
for (const entry of entries) {
|
|
62
|
+
if (entry.startsWith(".") && SKIP_DIRS.has(entry)) continue;
|
|
63
|
+
if (SKIP_DIRS.has(entry)) continue;
|
|
64
|
+
const full = join(dir, entry);
|
|
65
|
+
let isDir = false;
|
|
66
|
+
try {
|
|
67
|
+
isDir = statSync(full).isDirectory();
|
|
68
|
+
} catch {
|
|
69
|
+
continue;
|
|
70
|
+
}
|
|
71
|
+
if (isDir) {
|
|
72
|
+
walk(full, depth + 1);
|
|
73
|
+
} else if (extname(entry).toLowerCase() === ".md") {
|
|
74
|
+
const key = noteKey(entry);
|
|
75
|
+
(index[key] ??= []).push(full);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
walk(resolve(root), 0);
|
|
81
|
+
return index;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** The name a link and a filename are compared by: no extension, no case. */
|
|
85
|
+
function noteKey(name: string): string {
|
|
86
|
+
return basename(name, extname(name)).trim().toLowerCase();
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Turn a task's link into a file.
|
|
91
|
+
*
|
|
92
|
+
* A `path` link is a real path and resolves against the board that carries the
|
|
93
|
+
* task — the same way a relative link works in any markdown document. A
|
|
94
|
+
* `wikilink` is only a name, so it goes through the index.
|
|
95
|
+
*
|
|
96
|
+
* When several files share a name the nearest to the board wins, and the ones
|
|
97
|
+
* it shadowed are returned: proximity is a guess, and a guess should be
|
|
98
|
+
* visible rather than silently applied.
|
|
99
|
+
*/
|
|
100
|
+
export function resolveNote(
|
|
101
|
+
link: TaskNoteLink,
|
|
102
|
+
{ index, boardPath }: { index: NoteIndex; boardPath: string },
|
|
103
|
+
): ResolvedNote | MissingNote {
|
|
104
|
+
const boardDir = dirname(resolve(boardPath));
|
|
105
|
+
|
|
106
|
+
if (link.kind === "path") {
|
|
107
|
+
const path = resolve(boardDir, link.target);
|
|
108
|
+
try {
|
|
109
|
+
if (statSync(path).isFile()) return { path };
|
|
110
|
+
} catch {
|
|
111
|
+
/* fall through to missing */
|
|
112
|
+
}
|
|
113
|
+
return { missing: link.target };
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const hits = index[noteKey(link.target)];
|
|
117
|
+
if (!hits || hits.length === 0) return { missing: link.target };
|
|
118
|
+
|
|
119
|
+
const ranked = [...hits].sort((a, b) => distanceFrom(boardDir, a) - distanceFrom(boardDir, b));
|
|
120
|
+
const [winner, ...rest] = ranked;
|
|
121
|
+
return rest.length > 0 ? { path: winner!, shadowed: rest } : { path: winner! };
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** How far a file sits from a directory, in path segments. Lower is nearer. */
|
|
125
|
+
function distanceFrom(dir: string, file: string): number {
|
|
126
|
+
const from = dir.split(sep).filter(Boolean);
|
|
127
|
+
const to = dirname(file).split(sep).filter(Boolean);
|
|
128
|
+
let common = 0;
|
|
129
|
+
while (common < from.length && common < to.length && from[common] === to[common]) common++;
|
|
130
|
+
return from.length - common + (to.length - common);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* The note's text, without its frontmatter — that is configuration, and the
|
|
135
|
+
* detail view is showing context.
|
|
136
|
+
*
|
|
137
|
+
* Throws when the file cannot be read: the caller has a frame to put the
|
|
138
|
+
* message in, and swallowing it would show an empty note instead of a reason.
|
|
139
|
+
*/
|
|
140
|
+
export function readNoteBody(path: string): string {
|
|
141
|
+
const raw = readFileSync(path, "utf-8");
|
|
142
|
+
const withoutFrontmatter = raw.replace(/^---\r?\n[\s\S]*?\r?\n---\r?\n?/, "");
|
|
143
|
+
return withoutFrontmatter.trim();
|
|
144
|
+
}
|
package/src/parser/markdown.ts
CHANGED
|
@@ -26,6 +26,7 @@ import type {
|
|
|
26
26
|
RawOther,
|
|
27
27
|
SectionBreak,
|
|
28
28
|
Task,
|
|
29
|
+
TaskNoteLink,
|
|
29
30
|
TimeBlock,
|
|
30
31
|
TimeBlockSource,
|
|
31
32
|
} from "~/types";
|
|
@@ -268,6 +269,12 @@ function parseTask(input: ParseTaskInput): Task {
|
|
|
268
269
|
// Wikilinks — capture displayed text (alias if present, target otherwise)
|
|
269
270
|
const wikilinks = Array.from(body.matchAll(RE_WIKILINK), (m) => m[2] ?? m[1]!);
|
|
270
271
|
|
|
272
|
+
// The task's note: a link that WRAPS the title, i.e. opens the line. A link
|
|
273
|
+
// in the middle of a sentence is a mention — `Mandare materiale a [[Lisa]]`
|
|
274
|
+
// is not a task documented by Lisa's page. Trailing metadata (dates, tags)
|
|
275
|
+
// does not disqualify it, only text before the link does.
|
|
276
|
+
const note = titleNoteLink(body);
|
|
277
|
+
|
|
271
278
|
// Priority
|
|
272
279
|
let priority: PriorityLevel = "none";
|
|
273
280
|
for (const [emoji, level] of PRIORITY_EMOJI) {
|
|
@@ -290,6 +297,7 @@ function parseTask(input: ParseTaskInput): Task {
|
|
|
290
297
|
assignee: am?.[1],
|
|
291
298
|
tags,
|
|
292
299
|
wikilinks,
|
|
300
|
+
note,
|
|
293
301
|
scheduled: sched?.[1],
|
|
294
302
|
due: due?.[1],
|
|
295
303
|
start: start?.[1],
|
|
@@ -324,6 +332,10 @@ function buildDisplayTitle(
|
|
|
324
332
|
t = t.replace(RE_TAG, "");
|
|
325
333
|
// Replace wikilinks with their displayed text (alias or target)
|
|
326
334
|
t = t.replace(RE_WIKILINK, (_m, target: string, alias?: string) => alias ?? target);
|
|
335
|
+
// Same for markdown links: show the text, drop the target. Without this a
|
|
336
|
+
// board written in plain markdown — the form that needs no Obsidian — shows
|
|
337
|
+
// `[Titolo](Tasks/Nota.md)` as its title everywhere, widget included.
|
|
338
|
+
t = t.replace(RE_MDLINK, (_m, text: string) => text);
|
|
327
339
|
// Strip priority and decorative emoji
|
|
328
340
|
for (const [emoji] of PRIORITY_EMOJI) t = t.replaceAll(emoji, "");
|
|
329
341
|
for (const emoji of DECORATIVE_EMOJI) t = t.replaceAll(emoji, "");
|
|
@@ -332,6 +344,45 @@ function buildDisplayTitle(
|
|
|
332
344
|
return t;
|
|
333
345
|
}
|
|
334
346
|
|
|
347
|
+
/** `[[Target|shown]]` or `[shown](path.md)` at the very start of the line. */
|
|
348
|
+
/** `[shown](target)` anywhere in the line — for display only. */
|
|
349
|
+
const RE_MDLINK = /\[([^\]]*)\]\(([^)]+)\)/g;
|
|
350
|
+
|
|
351
|
+
const RE_TITLE_WIKILINK = /^\s*\[\[([^\]|]+?)(?:\|[^\]]+)?\]\]/;
|
|
352
|
+
const RE_TITLE_MDLINK = /^\s*\[[^\]]*\]\(([^)]+)\)/;
|
|
353
|
+
|
|
354
|
+
/**
|
|
355
|
+
* What can sit between the checkbox and the title without being the title:
|
|
356
|
+
* priority and decorative emoji, and markdown emphasis. `- [ ] 🔥 [[Nota]]`
|
|
357
|
+
* is still a task whose title is a link.
|
|
358
|
+
*/
|
|
359
|
+
function stripTitleDecorations(body: string): string {
|
|
360
|
+
let t = body;
|
|
361
|
+
let changed = true;
|
|
362
|
+
while (changed) {
|
|
363
|
+
const before = t;
|
|
364
|
+
t = t.trimStart().replace(/^[*_~]+/, "");
|
|
365
|
+
for (const [emoji] of PRIORITY_EMOJI) if (t.startsWith(emoji)) t = t.slice(emoji.length);
|
|
366
|
+
for (const emoji of DECORATIVE_EMOJI) if (t.startsWith(emoji)) t = t.slice(emoji.length);
|
|
367
|
+
changed = t !== before;
|
|
368
|
+
}
|
|
369
|
+
return t;
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
function titleNoteLink(rawBody: string): TaskNoteLink | undefined {
|
|
373
|
+
const body = stripTitleDecorations(rawBody);
|
|
374
|
+
const wiki = body.match(RE_TITLE_WIKILINK);
|
|
375
|
+
if (wiki) return { target: wiki[1]!.trim(), kind: "wikilink" };
|
|
376
|
+
const md = body.match(RE_TITLE_MDLINK);
|
|
377
|
+
if (md) {
|
|
378
|
+
const target = md[1]!.trim();
|
|
379
|
+
// A URL is not a note. Nothing to read from the filesystem.
|
|
380
|
+
if (/^[a-z][a-z0-9+.-]*:\/\//i.test(target)) return undefined;
|
|
381
|
+
return { target, kind: "path" };
|
|
382
|
+
}
|
|
383
|
+
return undefined;
|
|
384
|
+
}
|
|
385
|
+
|
|
335
386
|
function extractBoardName(frontmatter: string, filepath: string): string {
|
|
336
387
|
const m = frontmatter.match(/^name:\s*(.+?)\s*$/m);
|
|
337
388
|
if (m) return m[1]!;
|
package/src/store/index.ts
CHANGED
|
@@ -46,6 +46,7 @@ import { createBoardFile } from "~/boards/create";
|
|
|
46
46
|
import { scanDirectory, type BoardCandidate } from "~/boards/scan";
|
|
47
47
|
import { suggestBoardsDir } from "~/boards/suggest";
|
|
48
48
|
import { isTask, parseBoard } from "~/parser/markdown";
|
|
49
|
+
import { buildNoteIndex, readNoteBody, resolveNote, type NoteIndex } from "~/notes/index";
|
|
49
50
|
import { buildRing, ringPosition, samePane, stepRing, type Pane } from "~/ui/pane-ring";
|
|
50
51
|
import { serializeBoard } from "~/parser/serialize";
|
|
51
52
|
import type {
|
|
@@ -122,6 +123,18 @@ export interface BoardNew {
|
|
|
122
123
|
error?: string;
|
|
123
124
|
}
|
|
124
125
|
|
|
126
|
+
/** What the detail view needs to show a task's note, or to explain its absence. */
|
|
127
|
+
export interface TaskNoteView {
|
|
128
|
+
path?: string;
|
|
129
|
+
body?: string;
|
|
130
|
+
/** Same-named notes that lost to this one. */
|
|
131
|
+
shadowed?: string[];
|
|
132
|
+
/** The link pointed at something that is not there. */
|
|
133
|
+
missing?: string;
|
|
134
|
+
/** The file exists but could not be read. */
|
|
135
|
+
error?: string;
|
|
136
|
+
}
|
|
137
|
+
|
|
125
138
|
export interface EventPicker {
|
|
126
139
|
step: 1 | 2;
|
|
127
140
|
/** Selection index into `cals` (step 2). */
|
|
@@ -1295,11 +1308,49 @@ export function createTuiStore({ config }: CreateStoreOptions) {
|
|
|
1295
1308
|
function refreshAll(): void {
|
|
1296
1309
|
setState("boards", loadAll(config));
|
|
1297
1310
|
setState("rev", (r) => r + 1);
|
|
1311
|
+
noteIndex = undefined; // rebuilt on the next note opened
|
|
1298
1312
|
agentsStore.refresh();
|
|
1299
1313
|
calendarStore.refresh(true);
|
|
1300
1314
|
flashBanner("info", "Refreshed boards · agents · agenda");
|
|
1301
1315
|
}
|
|
1302
1316
|
|
|
1317
|
+
// ─── Task notes ──────────────────────────────────────────────────────────
|
|
1318
|
+
// The index is built the first time a note is opened and thrown away by `r`.
|
|
1319
|
+
// Watching the tree instead would cost a second chokidar over a thousand
|
|
1320
|
+
// files to save a keystroke the user already has.
|
|
1321
|
+
let noteIndex: NoteIndex | undefined;
|
|
1322
|
+
|
|
1323
|
+
/**
|
|
1324
|
+
* The note behind the task at `ref`, ready to render: its text, where it came
|
|
1325
|
+
* from, or why it could not be read. `undefined` means the task has no note —
|
|
1326
|
+
* which is most tasks, and must produce no message at all.
|
|
1327
|
+
*/
|
|
1328
|
+
function taskNote(ref: TaskRef): TaskNoteView | undefined {
|
|
1329
|
+
const task = getTask(ref);
|
|
1330
|
+
if (!task?.note) return undefined;
|
|
1331
|
+
|
|
1332
|
+
noteIndex ??= buildNoteIndex(suggestBoardsDir(config));
|
|
1333
|
+
const found = resolveNote(task.note, { index: noteIndex, boardPath: ref.boardPath });
|
|
1334
|
+
if ("missing" in found) return { missing: found.missing };
|
|
1335
|
+
|
|
1336
|
+
// Shown from the boards' own root: an absolute path is noise, and the
|
|
1337
|
+
// config's root is the config's directory, not where the notes live.
|
|
1338
|
+
const root = suggestBoardsDir(config);
|
|
1339
|
+
const shown = found.path.startsWith(root)
|
|
1340
|
+
? found.path.slice(root.length).replace(/^[/\\]/, "")
|
|
1341
|
+
: found.path;
|
|
1342
|
+
|
|
1343
|
+
try {
|
|
1344
|
+
return {
|
|
1345
|
+
path: shown,
|
|
1346
|
+
body: readNoteBody(found.path),
|
|
1347
|
+
shadowed: found.shadowed?.map((p) => (p.startsWith(root) ? p.slice(root.length + 1) : p)),
|
|
1348
|
+
};
|
|
1349
|
+
} catch (e) {
|
|
1350
|
+
return { path: shown, error: (e as Error).message };
|
|
1351
|
+
}
|
|
1352
|
+
}
|
|
1353
|
+
|
|
1303
1354
|
// ─── Multi-select ────────────────────────────────────────────────────────
|
|
1304
1355
|
|
|
1305
1356
|
function markKey(ref: TaskRef): string {
|
|
@@ -1643,6 +1694,8 @@ export function createTuiStore({ config }: CreateStoreOptions) {
|
|
|
1643
1694
|
addTask,
|
|
1644
1695
|
deleteTask,
|
|
1645
1696
|
moveTaskWithinBoard,
|
|
1697
|
+
// notes
|
|
1698
|
+
taskNote,
|
|
1646
1699
|
// boards
|
|
1647
1700
|
addBoard,
|
|
1648
1701
|
openBoardNew,
|
package/src/types.ts
CHANGED
|
@@ -20,6 +20,12 @@ export interface TimeBlock {
|
|
|
20
20
|
/** Where the time block was found in the source — drives writer behavior. */
|
|
21
21
|
export type TimeBlockSource = "legacy-prefix" | "watch-emoji";
|
|
22
22
|
|
|
23
|
+
/** Where a task's note lives: a note name (wiki style) or a path. */
|
|
24
|
+
export interface TaskNoteLink {
|
|
25
|
+
target: string;
|
|
26
|
+
kind: "wikilink" | "path";
|
|
27
|
+
}
|
|
28
|
+
|
|
23
29
|
export interface Task {
|
|
24
30
|
/** Stable identity within a board: `${columnIndex}:${indexInColumn}`. */
|
|
25
31
|
id: string;
|
|
@@ -38,6 +44,14 @@ export interface Task {
|
|
|
38
44
|
tags: string[];
|
|
39
45
|
/** Wikilinks: alias if present, otherwise target. */
|
|
40
46
|
wikilinks: string[];
|
|
47
|
+
/**
|
|
48
|
+
* The link that wraps the title, when there is one: this task's note.
|
|
49
|
+
*
|
|
50
|
+
* Distinct from `wikilinks`, which lists every link in the line. A link in
|
|
51
|
+
* the middle of a sentence is a mention — showing a person's page as a
|
|
52
|
+
* task's context would be worse than showing nothing.
|
|
53
|
+
*/
|
|
54
|
+
note?: TaskNoteLink;
|
|
41
55
|
scheduled?: ISODate;
|
|
42
56
|
due?: ISODate;
|
|
43
57
|
start?: ISODate;
|
package/src/ui/Modal.tsx
CHANGED
|
@@ -720,8 +720,9 @@ function DetailModal(props: { store: TuiStore; modal: Extract<NonNullable<TuiSto
|
|
|
720
720
|
if (!task) {
|
|
721
721
|
return <DialogShell title="Task not found" hint="Esc to close" width={50}><text>{" "}</text></DialogShell>;
|
|
722
722
|
}
|
|
723
|
+
const note = createMemo(() => props.store.taskNote(props.modal.ref));
|
|
723
724
|
return (
|
|
724
|
-
<DialogShell title="Detail" hint="Esc to close" width={90}>
|
|
725
|
+
<DialogShell title="Detail" hint="j/k scroll the note · Esc to close" width={90}>
|
|
725
726
|
<text wrapMode="word">
|
|
726
727
|
<span style={{ fg: T.text, attributes: ATTR.bold }}>{task.displayTitle}</span>
|
|
727
728
|
</text>
|
|
@@ -774,6 +775,48 @@ function DetailModal(props: { store: TuiStore; modal: Extract<NonNullable<TuiSto
|
|
|
774
775
|
<span style={{ fg: T.tag }}>{task.tags.map((t) => "#" + t).join(" ")}</span>
|
|
775
776
|
</text>
|
|
776
777
|
</Show>
|
|
778
|
+
{/* The task's note, when its title is a link. Most tasks have none, and
|
|
779
|
+
for those nothing is drawn at all: silence is the correct output. */}
|
|
780
|
+
<Show when={note()}>
|
|
781
|
+
{(n: () => NonNullable<ReturnType<typeof note>>) => (
|
|
782
|
+
<box style={{ flexDirection: "column", flexGrow: 1, minHeight: 0 }}>
|
|
783
|
+
<box style={{ height: 1 }} />
|
|
784
|
+
<Show when={n().path}>
|
|
785
|
+
<text wrapMode="word">
|
|
786
|
+
<span style={{ fg: T.textDim }}>Note: </span>
|
|
787
|
+
<span style={{ fg: T.tag }}>{n().path}</span>
|
|
788
|
+
</text>
|
|
789
|
+
</Show>
|
|
790
|
+
<Show when={n().missing}>
|
|
791
|
+
<text wrapMode="word">
|
|
792
|
+
<span style={{ fg: T.overdue }}>{"Note not found: " + n().missing}</span>
|
|
793
|
+
</text>
|
|
794
|
+
</Show>
|
|
795
|
+
<Show when={n().error}>
|
|
796
|
+
<text wrapMode="word">
|
|
797
|
+
<span style={{ fg: T.overdue }}>{"Note unreadable: " + n().error}</span>
|
|
798
|
+
</text>
|
|
799
|
+
</Show>
|
|
800
|
+
<Show when={n().shadowed?.length}>
|
|
801
|
+
<text wrapMode="word">
|
|
802
|
+
<span style={{ fg: T.textDim }}>
|
|
803
|
+
{"Another note shares this name: " + (n().shadowed ?? []).join(", ")}
|
|
804
|
+
</span>
|
|
805
|
+
</text>
|
|
806
|
+
</Show>
|
|
807
|
+
<Show when={n().body !== undefined}>
|
|
808
|
+
<box style={{ height: 1 }} />
|
|
809
|
+
<scrollbox style={{ flexGrow: 1, minHeight: 0 }}>
|
|
810
|
+
<text wrapMode="word">
|
|
811
|
+
<span style={{ fg: T.text }}>
|
|
812
|
+
{n().body === "" ? "(the note is empty)" : n().body}
|
|
813
|
+
</span>
|
|
814
|
+
</text>
|
|
815
|
+
</scrollbox>
|
|
816
|
+
</Show>
|
|
817
|
+
</box>
|
|
818
|
+
)}
|
|
819
|
+
</Show>
|
|
777
820
|
<Show when={task.wikilinks.length > 0}>
|
|
778
821
|
<box style={{ height: 1 }} />
|
|
779
822
|
<text>
|