@lamemind/loom-deck 0.57.3 → 0.58.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/dist/commit-times.js +66 -0
- package/dist/deck-model.js +8 -3
- package/dist/hooks.js +38 -0
- package/dist/model.js +1 -1
- package/dist/pane-views.js +1 -1
- package/dist/ui/modals.js +1 -1
- package/dist/ui/screens.js +1 -1
- package/dist/view-store.js +1 -1
- package/dist/view.js +11 -6
- package/package.json +1 -1
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
// T136 — data dell'ultimo commit che ha toccato ogni task file, in UNA
|
|
2
|
+
// passata di `git log` invece di uno spawn per file (vedi Description T136).
|
|
3
|
+
//
|
|
4
|
+
// `--format=#%ct` mette un prefisso che nessun path porta: la riga vuota
|
|
5
|
+
// compare anche FRA il timestamp e i path (non solo a fine blocco), quindi non
|
|
6
|
+
// può fare da separatore, e «tutte cifre» sarebbe una regola che un path
|
|
7
|
+
// potrebbe violare. Un merge commit senza `-m` non elenca path e produce un
|
|
8
|
+
// blocco senza righe path: si salta da sé, nessun ramo dedicato serve.
|
|
9
|
+
//
|
|
10
|
+
// git log esce in ordine cronologico DISCENDENTE: il primo hit di un path è
|
|
11
|
+
// già il suo ultimo commit, quindi si tiene solo il primo e si ignorano gli
|
|
12
|
+
// hit successivi dello stesso id.
|
|
13
|
+
import { execFile } from 'node:child_process';
|
|
14
|
+
import { basename } from 'node:path';
|
|
15
|
+
import { promisify } from 'node:util';
|
|
16
|
+
const execFileAsync = promisify(execFile);
|
|
17
|
+
const FORMAT_RE = /^#(\d+)$/;
|
|
18
|
+
// Il basename del task file, non il path intero (che cambia con la docs-root)
|
|
19
|
+
// e non `TASK_ID_RE` di tasks.ts (ancorata all'id nudo, non matcha `T136-x.md`).
|
|
20
|
+
const TASK_FILE_RE = /^(T\d+)-.*\.md$/;
|
|
21
|
+
/**
|
|
22
|
+
* Parsing puro dell'output `git log --format=#%ct --name-only`. Isolato da
|
|
23
|
+
* `commitTimes` perché è l'unica parte che vale la pena collaudare: invocare
|
|
24
|
+
* git davvero misurerebbe la storia di QUESTO repo, un valore che si muove a
|
|
25
|
+
* ogni commit.
|
|
26
|
+
*/
|
|
27
|
+
export function parseCommitLog(output) {
|
|
28
|
+
const out = new Map();
|
|
29
|
+
let epoch = null;
|
|
30
|
+
for (const raw of output.split('\n')) {
|
|
31
|
+
const line = raw.trim();
|
|
32
|
+
const m = FORMAT_RE.exec(line);
|
|
33
|
+
if (m) {
|
|
34
|
+
epoch = Number(m[1]);
|
|
35
|
+
continue;
|
|
36
|
+
}
|
|
37
|
+
if (!line || epoch === null)
|
|
38
|
+
continue;
|
|
39
|
+
const idm = TASK_FILE_RE.exec(basename(line));
|
|
40
|
+
if (!idm)
|
|
41
|
+
continue;
|
|
42
|
+
const id = idm[1];
|
|
43
|
+
if (!out.has(id))
|
|
44
|
+
out.set(id, epoch); // primo hit = il più recente
|
|
45
|
+
}
|
|
46
|
+
return out;
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* `Map<taskId, epoch>` dell'ultimo commit di ogni task file sotto `tasksDir`.
|
|
50
|
+
*
|
|
51
|
+
* git muto (repo assente, path fuori dal repo, git non installato) → mappa
|
|
52
|
+
* vuota, mai un throw, come `archivable.ts`: un dato di ordinamento non può
|
|
53
|
+
* rompere il deck. Nessun `--since`/`-n`: un cap renderebbe la mappa
|
|
54
|
+
* incompleta proprio per le task vecchie mai più toccate, quelle che sotto
|
|
55
|
+
* `desc` finiscono in coda — dove un timestamp assente e uno vecchio si
|
|
56
|
+
* confonderebbero.
|
|
57
|
+
*/
|
|
58
|
+
export async function commitTimes(tasksDir, projectRoot) {
|
|
59
|
+
try {
|
|
60
|
+
const { stdout } = await execFileAsync('git', ['log', '--format=#%ct', '--name-only', '--', tasksDir], { cwd: projectRoot });
|
|
61
|
+
return parseCommitLog(stdout);
|
|
62
|
+
}
|
|
63
|
+
catch {
|
|
64
|
+
return new Map();
|
|
65
|
+
}
|
|
66
|
+
}
|
package/dist/deck-model.js
CHANGED
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
// la ragione per cui i loro conti di parametri restano leggibili.
|
|
15
15
|
import { useEffect, useMemo, useState } from 'react';
|
|
16
16
|
import { loadArchivableDays, loadIdentity, loadLaunch } from './config.js';
|
|
17
|
-
import { useArchivable, useDirtyFolders, useInboxScan, useSessions, useTaskDetail, useTasks, } from './hooks.js';
|
|
17
|
+
import { useArchivable, useCommitTimes, useDirtyFolders, useInboxScan, useSessions, useTaskDetail, useTasks, } from './hooks.js';
|
|
18
18
|
import { DEFAULT_INBOX_STALE_HOURS, staleCount } from './inbox.js';
|
|
19
19
|
import { cycleInboxView, inboxCounts as deriveInboxCounts, inboxView, selectInboxRows, } from './inbox-views.js';
|
|
20
20
|
import { docsRootName } from './tasks.js';
|
|
@@ -159,9 +159,12 @@ export function useDeckModel({ cwd, tasksPath, tasksDir, setNote, }) {
|
|
|
159
159
|
// includeva l'owner.
|
|
160
160
|
const projectCore = identity ? identity.name : null;
|
|
161
161
|
const projectName = cwd.split('/').pop() || cwd;
|
|
162
|
+
// T136 — data dell'ultimo commit di ogni task file, per la chiave `commit`
|
|
163
|
+
// della chain di sort.
|
|
164
|
+
const commitAt = useCommitTimes(tasksDir, cwd);
|
|
162
165
|
// La vista è una trasformazione DERIVATA, applicata a valle del load: il
|
|
163
166
|
// polling di tasks.md continua a funzionare senza saperne nulla.
|
|
164
|
-
const { visible: viewTasks, hidden: hiddenTasks } = useMemo(() => applyView(tasks, view), [tasks, view]);
|
|
167
|
+
const { visible: viewTasks, hidden: hiddenTasks } = useMemo(() => applyView(tasks, view, { commitAt }), [tasks, view, commitAt]);
|
|
165
168
|
// T61 — il conteggio guarda la lista GREZZA, non `viewTasks`: le Done fuori
|
|
166
169
|
// dai filtri della vista restano archiviabili, e un contatore che cambiasse
|
|
167
170
|
// filtrando direbbe qualcosa sulla vista invece che sulla task list.
|
|
@@ -197,7 +200,9 @@ export function useDeckModel({ cwd, tasksPath, tasksDir, setNote, }) {
|
|
|
197
200
|
hidden: hiddenTasks,
|
|
198
201
|
archivable: archivable.size,
|
|
199
202
|
};
|
|
200
|
-
const paneTasks = useMemo(() => taskViewId === 'tasks'
|
|
203
|
+
const paneTasks = useMemo(() => taskViewId === 'tasks'
|
|
204
|
+
? viewTasks
|
|
205
|
+
: selectTasks(tasks, taskViewId, { view, archivable, commitAt }), [taskViewId, viewTasks, tasks, view, archivable, commitAt]);
|
|
201
206
|
const isSpot = sel === SPOT;
|
|
202
207
|
const isAll = sel === ALL;
|
|
203
208
|
// T112 — quando `CANC` pota in BLOCCO invece della sola task selezionata.
|
package/dist/hooks.js
CHANGED
|
@@ -10,6 +10,7 @@ import { discoverProjectSessions } from './sessions.js';
|
|
|
10
10
|
import { discoverLiveSessions, liveSig } from './live-sessions.js';
|
|
11
11
|
import { loadSessionIndex } from './task-index.js';
|
|
12
12
|
import { archivableIds, SCAN_INTERVAL_MS } from './archivable.js';
|
|
13
|
+
import { commitTimes } from './commit-times.js';
|
|
13
14
|
import { scanInbox, INBOX_SCAN_INTERVAL_MS } from './inbox.js';
|
|
14
15
|
import { mixedCount, readWrapCache, runWrapScan, wrapCacheFile, wrapCount, } from './wrap-scan.js';
|
|
15
16
|
import { purgeTargets } from './purge.js';
|
|
@@ -67,6 +68,43 @@ export function useTasks(tasksPath) {
|
|
|
67
68
|
}, [tasksPath]);
|
|
68
69
|
return { tasks, loadError };
|
|
69
70
|
}
|
|
71
|
+
/**
|
|
72
|
+
* T136 — data dell'ultimo commit di ogni task file, per la chiave di sort
|
|
73
|
+
* `commit`. D1 (preflight): sullo STESSO poll di `tasks.md` (`POLL_MS`), non
|
|
74
|
+
* su una scala propria come `useArchivable` — una passata di `git log` costa
|
|
75
|
+
* ~33ms ed è UNA invocazione per tick, indipendentemente dal numero di task.
|
|
76
|
+
*
|
|
77
|
+
* P5 (preflight): `commitTimes` ritorna una `Map` nuova a ogni tick anche
|
|
78
|
+
* quando il contenuto non cambia; passata nuda a una `useMemo` a valle ne
|
|
79
|
+
* romperebbe la memoizzazione ogni 1,5s. Si confronta per FIRMA prima di
|
|
80
|
+
* aggiornare lo stato, come `lastMtime` in `useTasks` e `lastSig` in
|
|
81
|
+
* `useSessions`: l'identità della mappa cambia solo dopo un commit vero.
|
|
82
|
+
*/
|
|
83
|
+
export function useCommitTimes(tasksDir, projectRoot) {
|
|
84
|
+
const [commitAt, setCommitAt] = useState(() => new Map());
|
|
85
|
+
useEffect(() => {
|
|
86
|
+
let lastSig = '';
|
|
87
|
+
let alive = true;
|
|
88
|
+
const reload = () => {
|
|
89
|
+
commitTimes(tasksDir, projectRoot).then((next) => {
|
|
90
|
+
if (!alive)
|
|
91
|
+
return;
|
|
92
|
+
const sig = [...next.entries()].map(([id, ts]) => `${id}:${ts}`).sort().join(',');
|
|
93
|
+
if (sig === lastSig)
|
|
94
|
+
return;
|
|
95
|
+
lastSig = sig;
|
|
96
|
+
setCommitAt(next);
|
|
97
|
+
});
|
|
98
|
+
};
|
|
99
|
+
reload();
|
|
100
|
+
const id = setInterval(reload, POLL_MS);
|
|
101
|
+
return () => {
|
|
102
|
+
alive = false;
|
|
103
|
+
clearInterval(id);
|
|
104
|
+
};
|
|
105
|
+
}, [tasksDir, projectRoot]);
|
|
106
|
+
return commitAt;
|
|
107
|
+
}
|
|
70
108
|
// Poll delle sessioni del progetto + binding sidecar. discoverProjectSessions
|
|
71
109
|
// ha cache mtime-keyed interna → il poll è economico; qui si evita comunque il
|
|
72
110
|
// re-render inutile con una signature (sessionId:ts + binding entries): setState
|
package/dist/model.js
CHANGED
|
@@ -33,7 +33,7 @@ export const ROW_SPOT = 1;
|
|
|
33
33
|
export const META_ROWS = 2;
|
|
34
34
|
// Modale sort a grammatica libera: un tasto per chiave, pressioni successive
|
|
35
35
|
// ciclano asc → desc → fuori dalla chain.
|
|
36
|
-
export const SORT_TASTI = { p: 'pri', s: 'prog', i: 'id' };
|
|
36
|
+
export const SORT_TASTI = { p: 'pri', s: 'prog', i: 'id', c: 'commit' };
|
|
37
37
|
// T52 — toggle del modale ricerca, tutti su CTRL (D2).
|
|
38
38
|
//
|
|
39
39
|
// I due campi di testo mangiano ogni lettera nuda, quindi un toggle non può
|
package/dist/pane-views.js
CHANGED
|
@@ -57,7 +57,7 @@ export const TASK_VIEWS = [
|
|
|
57
57
|
export function selectTasks(tasks, id, ctx) {
|
|
58
58
|
const entry = taskView(id);
|
|
59
59
|
const picked = tasks.filter((t) => entry.has(t, ctx));
|
|
60
|
-
picked.sort((a, b) => compareTasks(a, b, ctx.view.sort));
|
|
60
|
+
picked.sort((a, b) => compareTasks(a, b, ctx.view.sort, ctx));
|
|
61
61
|
return picked;
|
|
62
62
|
}
|
|
63
63
|
/** Glifo della sessione viva, gemello di `LIVE_IDLE` del render. Duplicato qui
|
package/dist/ui/modals.js
CHANGED
|
@@ -9,7 +9,7 @@ import { CARET, CARET_OFF, WARN } from '../glyphs.js';
|
|
|
9
9
|
import { EDIT_PRI, EDIT_PROG } from '../model.js';
|
|
10
10
|
import { PRI_ENTRIES, PROG_ENTRIES } from '../view.js';
|
|
11
11
|
import { progressText, PRI_GLYPH, PRI_LABEL, PROG_GLYPH } from '../task-edit.js';
|
|
12
|
-
export const SORT_UI = { pri: 'pri', prog: 'stato', id: 'id' };
|
|
12
|
+
export const SORT_UI = { pri: 'pri', prog: 'stato', id: 'id', commit: 'commit' };
|
|
13
13
|
// Modali resi IN FLUSSO (come l'input box di create), non in overlay assoluto:
|
|
14
14
|
// spingono giù i pane invece di coprirli, così la lista che stai filtrando
|
|
15
15
|
// resta sempre visibile mentre la componi.
|
package/dist/ui/screens.js
CHANGED
|
@@ -71,7 +71,7 @@ export function HintBar({ mode, purge, keyLegend, indicators, columns, }) {
|
|
|
71
71
|
return (_jsxs(Text, { dimColor: true, wrap: "truncate-end", children: ["nuova task \u00B7 ", _jsx(Text, { color: "yellow", children: "\u23CE" }), " crea \u00B7 ", _jsx(Text, { color: "yellow", children: "esc" }), " annulla"] }));
|
|
72
72
|
}
|
|
73
73
|
if (mode === 'sort') {
|
|
74
|
-
return (_jsxs(Text, { dimColor: true, wrap: "truncate-end", children: ["sort \u00B7 ", _jsx(Text, { color: "yellow", children: "p" }), " pri ", _jsx(Text, { color: "yellow", children: "s" }), " stato", ' ', _jsx(Text, { color: "yellow", children: "i" }), " id (asc\u2192desc\u2192off) \u00B7
|
|
74
|
+
return (_jsxs(Text, { dimColor: true, wrap: "truncate-end", children: ["sort \u00B7 ", _jsx(Text, { color: "yellow", children: "p" }), " pri ", _jsx(Text, { color: "yellow", children: "s" }), " stato", ' ', _jsx(Text, { color: "yellow", children: "i" }), " id ", _jsx(Text, { color: "yellow", children: "c" }), " commit (asc\u2192desc\u2192off) \u00B7", ' ', _jsx(Text, { color: "yellow", children: "\u23CE" }), " ok \u00B7 ", _jsx(Text, { color: "yellow", children: "esc" }), " annulla"] }));
|
|
75
75
|
}
|
|
76
76
|
if (mode === 'filter') {
|
|
77
77
|
return (_jsxs(Text, { dimColor: true, wrap: "truncate-end", children: ["filtri \u00B7 ", _jsx(Text, { color: "yellow", children: "\u2191\u2193\u2190\u2192" }), " naviga \u00B7 ", _jsx(Text, { color: "yellow", children: "spazio" }), ' ', "mostra/nascondi \u00B7 ", _jsx(Text, { color: "yellow", children: "\u23CE" }), " ok \u00B7 ", _jsx(Text, { color: "yellow", children: "esc" }), " annulla"] }));
|
package/dist/view-store.js
CHANGED
|
@@ -10,7 +10,7 @@ import { DEFAULT_VIEW, PRI_ENTRIES, PROG_ENTRIES, } from './view.js';
|
|
|
10
10
|
export function viewFilePath(projectRoot) {
|
|
11
11
|
return join(projectRoot, '.claude', 'loom', 'deck-view.json');
|
|
12
12
|
}
|
|
13
|
-
const SORT_KEYS = ['pri', 'prog', 'id'];
|
|
13
|
+
const SORT_KEYS = ['pri', 'prog', 'id', 'commit'];
|
|
14
14
|
const PRI_NAMES = PRI_ENTRIES.map((e) => e.name);
|
|
15
15
|
const PROG_NAMES = PROG_ENTRIES.map((e) => e.name);
|
|
16
16
|
// Il file è editabile a mano e sopravvive ai cambi di schema: si tiene solo ciò
|
package/dist/view.js
CHANGED
|
@@ -116,11 +116,16 @@ export function taskColumns(tasks, data) {
|
|
|
116
116
|
}
|
|
117
117
|
return { id: idColumnWidth(tasks), tail: tail > 0 ? tail + 1 : 0 };
|
|
118
118
|
}
|
|
119
|
-
|
|
119
|
+
// La chiave `commit` non ha un glifo: il rango è l'epoch stesso. Assente →
|
|
120
|
+
// UNKNOWN_RANK (0), sempre più basso di un epoch reale → coda sotto `desc`,
|
|
121
|
+
// stessa semantica del glifo non riconosciuto per `pri`/`prog`.
|
|
122
|
+
function rankOf(task, key, ctx) {
|
|
120
123
|
if (key === 'pri')
|
|
121
124
|
return priRank(task.pri);
|
|
122
125
|
if (key === 'prog')
|
|
123
126
|
return progRank(task.prog);
|
|
127
|
+
if (key === 'commit')
|
|
128
|
+
return ctx.commitAt.get(task.id) ?? UNKNOWN_RANK;
|
|
124
129
|
return idNum(task.id);
|
|
125
130
|
}
|
|
126
131
|
/**
|
|
@@ -129,9 +134,9 @@ function rankOf(task, key) {
|
|
|
129
134
|
* SEMPRE deterministico (mai instabile fra re-render). Se `id` è già una chiave
|
|
130
135
|
* esplicita della chain il fallback non serve: l'id è unico, la parità è totale.
|
|
131
136
|
*/
|
|
132
|
-
export function compareTasks(a, b, sort) {
|
|
137
|
+
export function compareTasks(a, b, sort, ctx) {
|
|
133
138
|
for (const entry of sort) {
|
|
134
|
-
const diff = rankOf(a, entry.key) - rankOf(b, entry.key);
|
|
139
|
+
const diff = rankOf(a, entry.key, ctx) - rankOf(b, entry.key, ctx);
|
|
135
140
|
if (diff !== 0)
|
|
136
141
|
return entry.dir === 'asc' ? diff : -diff;
|
|
137
142
|
}
|
|
@@ -174,14 +179,14 @@ export function isVisible(task, view) {
|
|
|
174
179
|
return true;
|
|
175
180
|
}
|
|
176
181
|
/** Filtra poi ordina. Non muta l'input: il polling di tasks.md resta ignaro. */
|
|
177
|
-
export function applyView(tasks, view) {
|
|
182
|
+
export function applyView(tasks, view, ctx) {
|
|
178
183
|
const visible = tasks.filter((t) => isVisible(t, view));
|
|
179
|
-
visible.sort((a, b) => compareTasks(a, b, view.sort));
|
|
184
|
+
visible.sort((a, b) => compareTasks(a, b, view.sort, ctx));
|
|
180
185
|
return { visible, hidden: tasks.length - visible.length };
|
|
181
186
|
}
|
|
182
187
|
export const PRI_ENTRIES = PRI_TABLE.map((e) => ({ name: e.name, glyph: e.glyph }));
|
|
183
188
|
export const PROG_ENTRIES = PROG_TABLE.map((e) => ({ name: e.name, glyph: e.glyph }));
|
|
184
|
-
const SORT_LABEL = { pri: 'pri', prog: 'stato', id: 'id' };
|
|
189
|
+
const SORT_LABEL = { pri: 'pri', prog: 'stato', id: 'id', commit: 'commit' };
|
|
185
190
|
/** Riassunto della chain per l'header ("pri↓ id↑"); vuota → "—". */
|
|
186
191
|
export function describeSort(sort) {
|
|
187
192
|
if (sort.length === 0)
|
package/package.json
CHANGED