@mindtnv/todoist-cli 0.3.1 → 0.5.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/marketplace.json +16 -0
- package/package.json +7 -6
- package/src/api/activity.ts +8 -0
- package/src/api/client.ts +214 -0
- package/src/api/comments.ts +18 -0
- package/src/api/completed.ts +15 -0
- package/src/api/labels.ts +18 -0
- package/src/api/projects.ts +22 -0
- package/src/api/sections.ts +20 -0
- package/src/api/stats.ts +38 -0
- package/src/api/tasks.ts +34 -0
- package/src/api/types.ts +202 -0
- package/src/cli/auth.ts +40 -0
- package/src/cli/commands/task/add.ts +328 -0
- package/src/cli/commands/task/complete.ts +62 -0
- package/src/cli/commands/task/delete.ts +62 -0
- package/src/cli/commands/task/helpers.ts +289 -0
- package/src/cli/commands/task/index.ts +27 -0
- package/src/cli/commands/task/list.ts +151 -0
- package/src/cli/commands/task/move.ts +49 -0
- package/src/cli/commands/task/reopen.ts +43 -0
- package/src/cli/commands/task/show.ts +115 -0
- package/src/cli/commands/task/update.ts +122 -0
- package/src/cli/comment.ts +83 -0
- package/src/cli/completed.ts +87 -0
- package/src/cli/completion.ts +360 -0
- package/src/cli/filter.ts +115 -0
- package/src/cli/index.ts +638 -0
- package/src/cli/label.ts +120 -0
- package/src/cli/log.ts +57 -0
- package/src/cli/matrix.ts +100 -0
- package/src/cli/plugin-loader.ts +38 -0
- package/src/cli/plugin.ts +289 -0
- package/src/cli/project.ts +172 -0
- package/src/cli/review.ts +116 -0
- package/src/cli/section.ts +98 -0
- package/src/cli/stats.ts +62 -0
- package/src/cli/template.ts +89 -0
- package/src/config/index.ts +229 -0
- package/src/plugins/api-proxy.ts +70 -0
- package/src/plugins/extension-registry.ts +53 -0
- package/src/plugins/hook-registry.ts +36 -0
- package/src/plugins/loader.ts +200 -0
- package/src/plugins/marketplace-types.ts +55 -0
- package/src/plugins/marketplace.ts +576 -0
- package/src/plugins/palette-registry.ts +21 -0
- package/src/plugins/storage.ts +101 -0
- package/src/plugins/types.ts +226 -0
- package/src/plugins/view-registry.ts +19 -0
- package/src/ui/App.tsx +234 -0
- package/src/ui/components/Breadcrumb.tsx +18 -0
- package/src/ui/components/CommandPalette.tsx +237 -0
- package/src/ui/components/ConfirmDialog.tsx +28 -0
- package/src/ui/components/EditTaskModal.tsx +484 -0
- package/src/ui/components/HelpOverlay.tsx +195 -0
- package/src/ui/components/InputPrompt.tsx +109 -0
- package/src/ui/components/LabelPicker.tsx +110 -0
- package/src/ui/components/ModalManager.tsx +275 -0
- package/src/ui/components/ProjectPicker.tsx +95 -0
- package/src/ui/components/Sidebar.tsx +282 -0
- package/src/ui/components/SortMenu.tsx +77 -0
- package/src/ui/components/StatusBar.tsx +67 -0
- package/src/ui/components/TaskList.tsx +258 -0
- package/src/ui/components/TaskRow.tsx +105 -0
- package/src/ui/hooks/useKeyboardHandler.ts +291 -0
- package/src/ui/hooks/useStatusMessage.ts +32 -0
- package/src/ui/hooks/useTaskOperations.ts +558 -0
- package/src/ui/hooks/useUndoSystem.ts +218 -0
- package/src/ui/views/ActivityView.tsx +213 -0
- package/src/ui/views/CompletedView.tsx +337 -0
- package/src/ui/views/StatsView.tsx +178 -0
- package/src/ui/views/TaskDetailView.tsx +438 -0
- package/src/ui/views/TasksView.tsx +851 -0
- package/src/utils/colors.ts +27 -0
- package/src/utils/date-format.ts +54 -0
- package/src/utils/errors.ts +159 -0
- package/src/utils/exit.ts +11 -0
- package/src/utils/format.ts +46 -0
- package/src/utils/open-url.ts +9 -0
- package/src/utils/output.ts +29 -0
- package/src/utils/quick-add.ts +202 -0
- package/src/utils/resolve.ts +359 -0
- package/src/utils/sorting.ts +27 -0
- package/src/utils/validation.ts +88 -0
- package/dist/index.js +0 -10989
|
@@ -0,0 +1,282 @@
|
|
|
1
|
+
import { useState, useEffect, useMemo } from "react";
|
|
2
|
+
import { Box, Text, useInput, useStdout } from "ink";
|
|
3
|
+
import type { Project, Label, Section } from "../../api/types.ts";
|
|
4
|
+
import { getSections } from "../../api/sections.ts";
|
|
5
|
+
import { mapTodoistColor } from "../../utils/colors.ts";
|
|
6
|
+
import type { PluginViewDefinition } from "../../plugins/types.ts";
|
|
7
|
+
|
|
8
|
+
const SIDEBAR_ICONS: Record<string, string> = {
|
|
9
|
+
inbox: "\u25A3",
|
|
10
|
+
today: "\u25C9",
|
|
11
|
+
upcoming: "\u25B7",
|
|
12
|
+
"view-stats": "\u2261",
|
|
13
|
+
"view-completed": "\u2713",
|
|
14
|
+
"view-activity": "\u2302",
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
export interface SidebarItem {
|
|
18
|
+
id: string;
|
|
19
|
+
label: string;
|
|
20
|
+
type: "builtin" | "separator" | "project" | "label" | "section" | "view";
|
|
21
|
+
color?: string;
|
|
22
|
+
taskCount?: number;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
interface SidebarProps {
|
|
26
|
+
projects: Project[];
|
|
27
|
+
labels: Label[];
|
|
28
|
+
tasks?: import("../../api/types.ts").Task[];
|
|
29
|
+
activeProjectId?: string;
|
|
30
|
+
selectedIndex: number;
|
|
31
|
+
isFocused: boolean;
|
|
32
|
+
onSelect: (item: SidebarItem) => void;
|
|
33
|
+
onIndexChange: (index: number) => void;
|
|
34
|
+
onNavigate?: (view: string) => void;
|
|
35
|
+
pluginViews?: PluginViewDefinition[];
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function buildSidebarItems(
|
|
39
|
+
projects: Project[],
|
|
40
|
+
labels: Label[],
|
|
41
|
+
tasks?: import("../../api/types.ts").Task[],
|
|
42
|
+
sections?: Section[],
|
|
43
|
+
activeProjectId?: string,
|
|
44
|
+
pluginViews?: PluginViewDefinition[],
|
|
45
|
+
): SidebarItem[] {
|
|
46
|
+
const taskCountByProject = new Map<string, number>();
|
|
47
|
+
const taskCountByLabel = new Map<string, number>();
|
|
48
|
+
if (tasks) {
|
|
49
|
+
for (const t of tasks) {
|
|
50
|
+
taskCountByProject.set(t.project_id, (taskCountByProject.get(t.project_id) ?? 0) + 1);
|
|
51
|
+
for (const l of t.labels) {
|
|
52
|
+
taskCountByLabel.set(l, (taskCountByLabel.get(l) ?? 0) + 1);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const inboxProject = projects.find((p) => p.is_inbox_project);
|
|
58
|
+
const today = new Date();
|
|
59
|
+
const localDate = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, "0")}-${String(today.getDate()).padStart(2, "0")}`;
|
|
60
|
+
const todayCount = tasks ? tasks.filter((t) => t.due?.date === localDate).length : undefined;
|
|
61
|
+
const upcomingCount = tasks ? tasks.filter((t) => t.due !== null && t.due.date >= localDate).length : undefined;
|
|
62
|
+
|
|
63
|
+
const items: SidebarItem[] = [
|
|
64
|
+
{ id: "inbox", label: "Inbox", type: "builtin", taskCount: inboxProject && tasks ? taskCountByProject.get(inboxProject.id) : undefined },
|
|
65
|
+
{ id: "today", label: "Today", type: "builtin", taskCount: todayCount },
|
|
66
|
+
{ id: "upcoming", label: "Upcoming", type: "builtin", taskCount: upcomingCount },
|
|
67
|
+
];
|
|
68
|
+
|
|
69
|
+
if (projects.length > 0) {
|
|
70
|
+
items.push({ id: "sep-projects", label: "Projects", type: "separator" });
|
|
71
|
+
for (const p of projects) {
|
|
72
|
+
if (!p.is_inbox_project) {
|
|
73
|
+
items.push({
|
|
74
|
+
id: p.id,
|
|
75
|
+
label: p.name,
|
|
76
|
+
type: "project",
|
|
77
|
+
color: mapTodoistColor(p.color),
|
|
78
|
+
taskCount: taskCountByProject.get(p.id),
|
|
79
|
+
});
|
|
80
|
+
// Show sections under the active project
|
|
81
|
+
if (activeProjectId && p.id === activeProjectId && sections) {
|
|
82
|
+
const projectSections = sections.filter((s) => s.project_id === p.id);
|
|
83
|
+
for (const s of projectSections) {
|
|
84
|
+
const sectionTaskCount = tasks
|
|
85
|
+
? tasks.filter((t) => t.section_id === s.id).length
|
|
86
|
+
: undefined;
|
|
87
|
+
items.push({
|
|
88
|
+
id: `section-${s.id}`,
|
|
89
|
+
label: ` ${s.name}`,
|
|
90
|
+
type: "section",
|
|
91
|
+
color: mapTodoistColor(p.color),
|
|
92
|
+
taskCount: sectionTaskCount,
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
if (labels.length > 0) {
|
|
101
|
+
items.push({ id: "sep-labels", label: "Labels", type: "separator" });
|
|
102
|
+
for (const l of labels) {
|
|
103
|
+
items.push({
|
|
104
|
+
id: l.id,
|
|
105
|
+
label: `@${l.name}`,
|
|
106
|
+
type: "label",
|
|
107
|
+
color: mapTodoistColor(l.color),
|
|
108
|
+
taskCount: taskCountByLabel.get(l.name),
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
items.push({ id: "sep-views", label: "Views", type: "separator" });
|
|
114
|
+
items.push({ id: "view-stats", label: "Stats", type: "view", color: "cyan" });
|
|
115
|
+
items.push({ id: "view-completed", label: "Completed", type: "view", color: "green" });
|
|
116
|
+
items.push({ id: "view-activity", label: "Activity", type: "view", color: "yellow" });
|
|
117
|
+
|
|
118
|
+
const sidebarPluginViews = pluginViews?.filter(v => v.sidebar) ?? [];
|
|
119
|
+
if (sidebarPluginViews.length > 0) {
|
|
120
|
+
items.push({ id: "sep-plugins", label: "Plugins", type: "separator" });
|
|
121
|
+
for (const pv of sidebarPluginViews) {
|
|
122
|
+
items.push({
|
|
123
|
+
id: `plugin-${pv.name}`,
|
|
124
|
+
label: pv.label,
|
|
125
|
+
type: "view",
|
|
126
|
+
color: "magenta",
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
return items;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
export function Sidebar({
|
|
135
|
+
projects,
|
|
136
|
+
labels,
|
|
137
|
+
tasks,
|
|
138
|
+
activeProjectId,
|
|
139
|
+
selectedIndex,
|
|
140
|
+
isFocused,
|
|
141
|
+
onSelect,
|
|
142
|
+
onIndexChange,
|
|
143
|
+
onNavigate,
|
|
144
|
+
pluginViews,
|
|
145
|
+
}: SidebarProps) {
|
|
146
|
+
const [sections, setSections] = useState<Section[]>([]);
|
|
147
|
+
const { stdout } = useStdout();
|
|
148
|
+
// Reserve lines for title, border, padding (~5 lines overhead)
|
|
149
|
+
const sidebarViewHeight = Math.max(5, (stdout?.rows ?? 24) - 5);
|
|
150
|
+
|
|
151
|
+
useEffect(() => {
|
|
152
|
+
if (!activeProjectId) {
|
|
153
|
+
setSections([]);
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
156
|
+
let cancelled = false;
|
|
157
|
+
getSections(activeProjectId)
|
|
158
|
+
.then((s) => {
|
|
159
|
+
if (!cancelled) setSections(s);
|
|
160
|
+
})
|
|
161
|
+
.catch(() => {
|
|
162
|
+
// Sections are optional; ignore errors
|
|
163
|
+
});
|
|
164
|
+
return () => {
|
|
165
|
+
cancelled = true;
|
|
166
|
+
};
|
|
167
|
+
}, [activeProjectId]);
|
|
168
|
+
|
|
169
|
+
const items = buildSidebarItems(projects, labels, tasks, sections, activeProjectId, pluginViews);
|
|
170
|
+
|
|
171
|
+
// Build icon map including plugin view icons
|
|
172
|
+
const iconMap = useMemo(() => {
|
|
173
|
+
const icons: Record<string, string> = { ...SIDEBAR_ICONS };
|
|
174
|
+
for (const pv of pluginViews ?? []) {
|
|
175
|
+
if (pv.sidebar?.icon) {
|
|
176
|
+
icons[`plugin-${pv.name}`] = pv.sidebar.icon;
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
return icons;
|
|
180
|
+
}, [pluginViews]);
|
|
181
|
+
|
|
182
|
+
// Compute adaptive sidebar width: clamp between 20 and 36
|
|
183
|
+
const sidebarWidth = useMemo(() => {
|
|
184
|
+
const lengths = items
|
|
185
|
+
.filter((item) => item.type !== "separator")
|
|
186
|
+
.map((item) => {
|
|
187
|
+
const countStr = item.taskCount != null ? ` (${item.taskCount})`.length : 0;
|
|
188
|
+
return item.label.length + countStr + 4; // 4 for prefix "> " and padding
|
|
189
|
+
});
|
|
190
|
+
return Math.min(38, Math.max(24, Math.max(...lengths, 24)));
|
|
191
|
+
}, [items]);
|
|
192
|
+
|
|
193
|
+
useInput(
|
|
194
|
+
(input, key) => {
|
|
195
|
+
if (!isFocused) return;
|
|
196
|
+
|
|
197
|
+
if (key.upArrow || input === "k") {
|
|
198
|
+
let next = selectedIndex - 1;
|
|
199
|
+
while (next >= 0 && items[next]?.type === "separator") next--;
|
|
200
|
+
if (next >= 0) onIndexChange(next);
|
|
201
|
+
} else if (key.downArrow || input === "j") {
|
|
202
|
+
let next = selectedIndex + 1;
|
|
203
|
+
while (next < items.length && items[next]?.type === "separator") next++;
|
|
204
|
+
if (next < items.length) onIndexChange(next);
|
|
205
|
+
} else if (key.return) {
|
|
206
|
+
const item = items[selectedIndex];
|
|
207
|
+
if (item && item.type === "view" && onNavigate) {
|
|
208
|
+
const viewName = item.id.startsWith("plugin-")
|
|
209
|
+
? item.id.replace("plugin-", "")
|
|
210
|
+
: item.id.replace("view-", "");
|
|
211
|
+
onNavigate(viewName);
|
|
212
|
+
} else if (item && item.type !== "separator" && item.type !== "view") {
|
|
213
|
+
onSelect(item);
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
},
|
|
217
|
+
);
|
|
218
|
+
|
|
219
|
+
return (
|
|
220
|
+
<Box
|
|
221
|
+
flexDirection="column"
|
|
222
|
+
width={sidebarWidth}
|
|
223
|
+
borderStyle="single"
|
|
224
|
+
borderColor={isFocused ? "green" : "gray"}
|
|
225
|
+
paddingX={1}
|
|
226
|
+
>
|
|
227
|
+
<Text bold color="green">Todoist</Text>
|
|
228
|
+
<Box marginTop={1} flexDirection="column">
|
|
229
|
+
{(() => {
|
|
230
|
+
// Viewport-based scrolling
|
|
231
|
+
let scrollStart = 0;
|
|
232
|
+
if (items.length > sidebarViewHeight) {
|
|
233
|
+
const half = Math.floor(sidebarViewHeight / 2);
|
|
234
|
+
scrollStart = Math.max(0, selectedIndex - half);
|
|
235
|
+
const scrollEnd = scrollStart + sidebarViewHeight;
|
|
236
|
+
if (scrollEnd > items.length) {
|
|
237
|
+
scrollStart = Math.max(0, items.length - sidebarViewHeight);
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
const visibleItems = items.length > sidebarViewHeight
|
|
241
|
+
? items.slice(scrollStart, scrollStart + sidebarViewHeight)
|
|
242
|
+
: items;
|
|
243
|
+
|
|
244
|
+
return visibleItems.map((item, vi) => {
|
|
245
|
+
const i = scrollStart + vi;
|
|
246
|
+
if (item.type === "separator") {
|
|
247
|
+
const isFirstSeparator = i === items.findIndex((it) => it.type === "separator");
|
|
248
|
+
return (
|
|
249
|
+
<Box key={item.id} marginTop={isFirstSeparator ? 0 : 1} flexDirection="column">
|
|
250
|
+
<Text color="gray" dimColor bold>
|
|
251
|
+
{item.label.toUpperCase()}
|
|
252
|
+
</Text>
|
|
253
|
+
</Box>
|
|
254
|
+
);
|
|
255
|
+
}
|
|
256
|
+
const isSelected = i === selectedIndex && isFocused;
|
|
257
|
+
const itemColor = isSelected ? "black" : item.color ?? (item.type === "builtin" ? "white" : "cyan");
|
|
258
|
+
const countStr = item.taskCount != null ? ` (${item.taskCount})` : "";
|
|
259
|
+
const prefix = i === selectedIndex ? "> " : " ";
|
|
260
|
+
const icon = iconMap[item.id];
|
|
261
|
+
const iconPrefix = icon ? `${icon} ` : "";
|
|
262
|
+
const fullLabel = `${iconPrefix}${item.label}`;
|
|
263
|
+
const maxLabelLen = sidebarWidth - 2 - prefix.length - countStr.length;
|
|
264
|
+
const displayLabel = fullLabel.length > maxLabelLen && maxLabelLen > 3
|
|
265
|
+
? fullLabel.slice(0, maxLabelLen - 1) + "\u2026"
|
|
266
|
+
: fullLabel;
|
|
267
|
+
return (
|
|
268
|
+
<Text
|
|
269
|
+
key={item.id}
|
|
270
|
+
backgroundColor={isSelected ? "green" : undefined}
|
|
271
|
+
color={itemColor}
|
|
272
|
+
bold={isSelected}
|
|
273
|
+
>
|
|
274
|
+
{prefix}{displayLabel}<Text color={isSelected ? "black" : "gray"}>{countStr}</Text>
|
|
275
|
+
</Text>
|
|
276
|
+
);
|
|
277
|
+
});
|
|
278
|
+
})()}
|
|
279
|
+
</Box>
|
|
280
|
+
</Box>
|
|
281
|
+
);
|
|
282
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { useState } from "react";
|
|
2
|
+
import { Box, Text, useInput } from "ink";
|
|
3
|
+
|
|
4
|
+
import type { SortField } from "../../utils/sorting.ts";
|
|
5
|
+
export type { SortField };
|
|
6
|
+
|
|
7
|
+
interface SortMenuProps {
|
|
8
|
+
currentSort: SortField;
|
|
9
|
+
currentDirection: "asc" | "desc";
|
|
10
|
+
onSelect: (field: SortField) => void;
|
|
11
|
+
onCancel: () => void;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const sortOptions: { field: SortField; label: string }[] = [
|
|
15
|
+
{ field: "priority", label: "Priority" },
|
|
16
|
+
{ field: "due", label: "Due date" },
|
|
17
|
+
{ field: "name", label: "Name" },
|
|
18
|
+
{ field: "project", label: "Project" },
|
|
19
|
+
];
|
|
20
|
+
|
|
21
|
+
export function SortMenu({ currentSort, currentDirection, onSelect, onCancel }: SortMenuProps) {
|
|
22
|
+
const currentIndex = sortOptions.findIndex((o) => o.field === currentSort);
|
|
23
|
+
const [selectedIndex, setSelectedIndex] = useState(currentIndex >= 0 ? currentIndex : 0);
|
|
24
|
+
|
|
25
|
+
useInput((input, key) => {
|
|
26
|
+
if (key.escape) {
|
|
27
|
+
onCancel();
|
|
28
|
+
return;
|
|
29
|
+
}
|
|
30
|
+
if (key.return) {
|
|
31
|
+
const option = sortOptions[selectedIndex];
|
|
32
|
+
if (option) {
|
|
33
|
+
onSelect(option.field);
|
|
34
|
+
}
|
|
35
|
+
return;
|
|
36
|
+
}
|
|
37
|
+
if (key.upArrow || input === "k") {
|
|
38
|
+
setSelectedIndex((i) => Math.max(0, i - 1));
|
|
39
|
+
} else if (key.downArrow || input === "j") {
|
|
40
|
+
setSelectedIndex((i) => Math.min(sortOptions.length - 1, i + 1));
|
|
41
|
+
}
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
return (
|
|
45
|
+
<Box
|
|
46
|
+
flexDirection="column"
|
|
47
|
+
borderStyle="single"
|
|
48
|
+
borderColor="magenta"
|
|
49
|
+
paddingX={2}
|
|
50
|
+
paddingY={1}
|
|
51
|
+
width={30}
|
|
52
|
+
>
|
|
53
|
+
<Box marginBottom={1}>
|
|
54
|
+
<Text bold color="magenta">Sort by</Text>
|
|
55
|
+
</Box>
|
|
56
|
+
{sortOptions.map((option, i) => {
|
|
57
|
+
const isActive = i === selectedIndex;
|
|
58
|
+
const isCurrent = option.field === currentSort;
|
|
59
|
+
const directionArrow = isCurrent ? (currentDirection === "asc" ? " ↑" : " ↓") : "";
|
|
60
|
+
return (
|
|
61
|
+
<Box key={option.field}>
|
|
62
|
+
<Text
|
|
63
|
+
backgroundColor={isActive ? "magenta" : undefined}
|
|
64
|
+
color={isActive ? "black" : isCurrent ? "magenta" : "white"}
|
|
65
|
+
bold={isActive}
|
|
66
|
+
>
|
|
67
|
+
{isActive ? "> " : " "}{option.label}{isCurrent ? " *" : ""}{directionArrow}
|
|
68
|
+
</Text>
|
|
69
|
+
</Box>
|
|
70
|
+
);
|
|
71
|
+
})}
|
|
72
|
+
<Box marginTop={1}>
|
|
73
|
+
<Text color="gray" dimColor>Arrow keys + Enter to select (same field toggles direction), Esc to cancel</Text>
|
|
74
|
+
</Box>
|
|
75
|
+
</Box>
|
|
76
|
+
);
|
|
77
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import React, { useState, useEffect, useRef } from "react";
|
|
2
|
+
import { Box, Text } from "ink";
|
|
3
|
+
import type { StatusBarItemDefinition, PluginContext } from "../../plugins/types.ts";
|
|
4
|
+
|
|
5
|
+
interface StatusBarProps {
|
|
6
|
+
items: StatusBarItemDefinition[];
|
|
7
|
+
contextMap: Map<string, PluginContext>;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function StatusBar({ items, contextMap }: StatusBarProps) {
|
|
11
|
+
const [tick, setTick] = useState(0);
|
|
12
|
+
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
|
13
|
+
|
|
14
|
+
useEffect(() => {
|
|
15
|
+
if (items.length === 0) return;
|
|
16
|
+
|
|
17
|
+
const intervals = items
|
|
18
|
+
.map((item) => item.refreshInterval)
|
|
19
|
+
.filter((v): v is number => typeof v === "number" && v > 0);
|
|
20
|
+
|
|
21
|
+
if (intervals.length === 0) return;
|
|
22
|
+
|
|
23
|
+
// Reset tick to prevent unbounded growth over long sessions
|
|
24
|
+
setTick(0);
|
|
25
|
+
|
|
26
|
+
const minInterval = Math.min(...intervals);
|
|
27
|
+
intervalRef.current = setInterval(() => {
|
|
28
|
+
// Use modular increment to avoid Number.MAX_SAFE_INTEGER overflow in very long sessions
|
|
29
|
+
setTick((t) => (t + 1) % 1_000_000);
|
|
30
|
+
}, minInterval);
|
|
31
|
+
|
|
32
|
+
return () => {
|
|
33
|
+
if (intervalRef.current) {
|
|
34
|
+
clearInterval(intervalRef.current);
|
|
35
|
+
intervalRef.current = null;
|
|
36
|
+
}
|
|
37
|
+
};
|
|
38
|
+
}, [items]);
|
|
39
|
+
|
|
40
|
+
if (items.length === 0) return null;
|
|
41
|
+
|
|
42
|
+
const rendered = items
|
|
43
|
+
.map((item) => {
|
|
44
|
+
const ctx = contextMap.get(item.id);
|
|
45
|
+
if (!ctx) return null;
|
|
46
|
+
const text = item.render(ctx);
|
|
47
|
+
if (!text) return null;
|
|
48
|
+
const color = item.color ? item.color(ctx) : undefined;
|
|
49
|
+
return { id: item.id, text, color };
|
|
50
|
+
})
|
|
51
|
+
.filter(Boolean) as { id: string; text: string; color: string | undefined }[];
|
|
52
|
+
|
|
53
|
+
if (rendered.length === 0) return null;
|
|
54
|
+
|
|
55
|
+
return (
|
|
56
|
+
<Box borderStyle="single" borderColor="gray" paddingX={1} justifyContent="space-between">
|
|
57
|
+
<Box gap={1}>
|
|
58
|
+
{rendered.map((item, i) => (
|
|
59
|
+
<React.Fragment key={item.id}>
|
|
60
|
+
{i > 0 && <Text color="gray" dimColor>{"\u2502"}</Text>}
|
|
61
|
+
<Text color={item.color}>{item.text}</Text>
|
|
62
|
+
</React.Fragment>
|
|
63
|
+
))}
|
|
64
|
+
</Box>
|
|
65
|
+
</Box>
|
|
66
|
+
);
|
|
67
|
+
}
|
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
import { useMemo, useState, useEffect, useRef } from "react";
|
|
2
|
+
import { Box, Text, useInput, useStdout } from "ink";
|
|
3
|
+
import type { Task } from "../../api/types.ts";
|
|
4
|
+
import type { TaskColumnDefinition, PluginContext } from "../../plugins/types.ts";
|
|
5
|
+
import { TaskRow } from "./TaskRow.tsx";
|
|
6
|
+
|
|
7
|
+
interface TaskListProps {
|
|
8
|
+
tasks: Task[];
|
|
9
|
+
selectedIndex: number;
|
|
10
|
+
isFocused: boolean;
|
|
11
|
+
onIndexChange: (index: number) => void;
|
|
12
|
+
selectedIds?: Set<string>;
|
|
13
|
+
viewHeight?: number;
|
|
14
|
+
sortField?: string;
|
|
15
|
+
searchQuery?: string;
|
|
16
|
+
pluginColumns?: TaskColumnDefinition[];
|
|
17
|
+
pluginColumnContextMap?: Map<string, PluginContext>;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
interface FlatTask {
|
|
21
|
+
task: Task;
|
|
22
|
+
depth: number;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function buildTree(tasks: Task[]): FlatTask[] {
|
|
26
|
+
const byParent = new Map<string | null, Task[]>();
|
|
27
|
+
for (const t of tasks) {
|
|
28
|
+
const parentKey = t.parent_id ?? null;
|
|
29
|
+
const existing = byParent.get(parentKey);
|
|
30
|
+
if (existing) {
|
|
31
|
+
existing.push(t);
|
|
32
|
+
} else {
|
|
33
|
+
byParent.set(parentKey, [t]);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const taskIds = new Set(tasks.map((t) => t.id));
|
|
38
|
+
const result: FlatTask[] = [];
|
|
39
|
+
|
|
40
|
+
function walk(parentId: string | null, depth: number) {
|
|
41
|
+
const children = byParent.get(parentId);
|
|
42
|
+
if (!children) return;
|
|
43
|
+
for (const child of children) {
|
|
44
|
+
result.push({ task: child, depth });
|
|
45
|
+
walk(child.id, depth + 1);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// Start with tasks whose parent is null or whose parent is not in the current set
|
|
50
|
+
const roots = tasks.filter((t) => t.parent_id === null || !taskIds.has(t.parent_id));
|
|
51
|
+
|
|
52
|
+
// Walk from roots
|
|
53
|
+
for (const root of roots) {
|
|
54
|
+
result.push({ task: root, depth: 0 });
|
|
55
|
+
walk(root.id, 1);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// Add any orphans (tasks whose parent is in set but weren't reached — shouldn't happen, but safety)
|
|
59
|
+
const visited = new Set(result.map((r) => r.task.id));
|
|
60
|
+
for (const t of tasks) {
|
|
61
|
+
if (!visited.has(t.id)) {
|
|
62
|
+
result.push({ task: t, depth: 0 });
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
return result;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function TaskList({
|
|
70
|
+
tasks,
|
|
71
|
+
selectedIndex,
|
|
72
|
+
isFocused,
|
|
73
|
+
onIndexChange,
|
|
74
|
+
selectedIds,
|
|
75
|
+
viewHeight: viewHeightProp,
|
|
76
|
+
sortField,
|
|
77
|
+
searchQuery,
|
|
78
|
+
pluginColumns,
|
|
79
|
+
pluginColumnContextMap,
|
|
80
|
+
}: TaskListProps) {
|
|
81
|
+
const { stdout } = useStdout();
|
|
82
|
+
// Reserve lines for header, border, status bar, etc. (~8 lines overhead)
|
|
83
|
+
const dynamicHeight = stdout?.rows ? Math.max(5, stdout.rows - 8) : 20;
|
|
84
|
+
const viewHeight = viewHeightProp ?? dynamicHeight;
|
|
85
|
+
const flatTasks = useMemo(() => buildTree(tasks), [tasks]);
|
|
86
|
+
|
|
87
|
+
const [pendingG, setPendingG] = useState(false);
|
|
88
|
+
const pendingGTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
89
|
+
|
|
90
|
+
useEffect(() => {
|
|
91
|
+
if (pendingG) {
|
|
92
|
+
pendingGTimer.current = setTimeout(() => setPendingG(false), 1000);
|
|
93
|
+
return () => { if (pendingGTimer.current) clearTimeout(pendingGTimer.current); };
|
|
94
|
+
}
|
|
95
|
+
}, [pendingG]);
|
|
96
|
+
|
|
97
|
+
useEffect(() => {
|
|
98
|
+
if (!isFocused) {
|
|
99
|
+
setPendingG(false);
|
|
100
|
+
if (pendingGTimer.current) clearTimeout(pendingGTimer.current);
|
|
101
|
+
}
|
|
102
|
+
}, [isFocused]);
|
|
103
|
+
|
|
104
|
+
useInput(
|
|
105
|
+
(input, key) => {
|
|
106
|
+
if (!isFocused) return;
|
|
107
|
+
|
|
108
|
+
if (pendingG) {
|
|
109
|
+
setPendingG(false);
|
|
110
|
+
if (input === "g") {
|
|
111
|
+
// gg -> go to top
|
|
112
|
+
onIndexChange(0);
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
// not gg, ignore
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
if (input === "g") {
|
|
120
|
+
setPendingG(true);
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
if (input === "G") {
|
|
124
|
+
onIndexChange(Math.max(0, flatTasks.length - 1));
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
if (key.ctrl && input === "d") {
|
|
128
|
+
onIndexChange(Math.min(flatTasks.length - 1, selectedIndex + Math.floor(viewHeight / 2)));
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
if (key.ctrl && input === "u") {
|
|
132
|
+
onIndexChange(Math.max(0, selectedIndex - Math.floor(viewHeight / 2)));
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
if (key.upArrow || input === "k") {
|
|
137
|
+
onIndexChange(Math.max(0, selectedIndex - 1));
|
|
138
|
+
} else if (key.downArrow || input === "j") {
|
|
139
|
+
onIndexChange(Math.min(flatTasks.length - 1, selectedIndex + 1));
|
|
140
|
+
}
|
|
141
|
+
},
|
|
142
|
+
);
|
|
143
|
+
|
|
144
|
+
if (flatTasks.length === 0) {
|
|
145
|
+
return (
|
|
146
|
+
<Box
|
|
147
|
+
flexDirection="column"
|
|
148
|
+
flexGrow={1}
|
|
149
|
+
borderStyle="single"
|
|
150
|
+
borderColor={isFocused ? "blue" : "gray"}
|
|
151
|
+
paddingX={1}
|
|
152
|
+
justifyContent="center"
|
|
153
|
+
alignItems="center"
|
|
154
|
+
>
|
|
155
|
+
<Text color="gray">No tasks here</Text>
|
|
156
|
+
<Box marginTop={1}>
|
|
157
|
+
<Text color="gray" dimColor>Press </Text>
|
|
158
|
+
<Text color="green">a</Text>
|
|
159
|
+
<Text color="gray" dimColor> to add a task or </Text>
|
|
160
|
+
<Text color="cyan">/</Text>
|
|
161
|
+
<Text color="gray" dimColor> to search</Text>
|
|
162
|
+
</Box>
|
|
163
|
+
</Box>
|
|
164
|
+
);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
const halfHeight = Math.floor(viewHeight / 2);
|
|
168
|
+
let scrollStart = Math.max(0, selectedIndex - halfHeight);
|
|
169
|
+
const scrollEnd = Math.min(flatTasks.length, scrollStart + viewHeight);
|
|
170
|
+
if (scrollEnd === flatTasks.length) {
|
|
171
|
+
scrollStart = Math.max(0, flatTasks.length - viewHeight);
|
|
172
|
+
}
|
|
173
|
+
const visibleTasks = flatTasks.slice(scrollStart, scrollEnd);
|
|
174
|
+
|
|
175
|
+
return (
|
|
176
|
+
<Box
|
|
177
|
+
flexDirection="column"
|
|
178
|
+
flexGrow={1}
|
|
179
|
+
borderStyle="single"
|
|
180
|
+
borderColor={isFocused ? "blue" : "gray"}
|
|
181
|
+
paddingX={1}
|
|
182
|
+
>
|
|
183
|
+
<Box marginBottom={1}>
|
|
184
|
+
<Text bold color="blue">Tasks</Text>
|
|
185
|
+
<Text color="gray">{` (${flatTasks.length})`}</Text>
|
|
186
|
+
</Box>
|
|
187
|
+
{sortField === "due" ? (
|
|
188
|
+
(() => {
|
|
189
|
+
const today = new Date();
|
|
190
|
+
const todayStr = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, "0")}-${String(today.getDate()).padStart(2, "0")}`;
|
|
191
|
+
const tomorrow = new Date(today);
|
|
192
|
+
tomorrow.setDate(tomorrow.getDate() + 1);
|
|
193
|
+
const tomorrowStr = `${tomorrow.getFullYear()}-${String(tomorrow.getMonth() + 1).padStart(2, "0")}-${String(tomorrow.getDate()).padStart(2, "0")}`;
|
|
194
|
+
let lastGroup = "";
|
|
195
|
+
// Track what group the task before the visible window was in
|
|
196
|
+
for (let k = 0; k < scrollStart; k++) {
|
|
197
|
+
const ft = flatTasks[k];
|
|
198
|
+
if (ft && ft.depth === 0) {
|
|
199
|
+
const d = ft.task.due?.date ?? "9999-99-99";
|
|
200
|
+
if (d < todayStr) lastGroup = "Overdue";
|
|
201
|
+
else if (d === todayStr) lastGroup = "Today";
|
|
202
|
+
else if (d === tomorrowStr) lastGroup = "Tomorrow";
|
|
203
|
+
else if (d === "9999-99-99") lastGroup = "No date";
|
|
204
|
+
else lastGroup = d;
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
return visibleTasks.map((item, i) => {
|
|
208
|
+
const dueDate = item.task.due?.date ?? "9999-99-99";
|
|
209
|
+
let group: string;
|
|
210
|
+
if (dueDate < todayStr) group = "Overdue";
|
|
211
|
+
else if (dueDate === todayStr) group = "Today";
|
|
212
|
+
else if (dueDate === tomorrowStr) group = "Tomorrow";
|
|
213
|
+
else if (dueDate === "9999-99-99") group = "No date";
|
|
214
|
+
else group = dueDate;
|
|
215
|
+
const showHeader = group !== lastGroup && item.depth === 0;
|
|
216
|
+
if (showHeader) lastGroup = group;
|
|
217
|
+
return (
|
|
218
|
+
<Box key={item.task.id} flexDirection="column">
|
|
219
|
+
{showHeader && (
|
|
220
|
+
<Text color="yellow" bold dimColor>{`-- ${group} --`}</Text>
|
|
221
|
+
)}
|
|
222
|
+
<TaskRow
|
|
223
|
+
task={item.task}
|
|
224
|
+
isSelected={scrollStart + i === selectedIndex}
|
|
225
|
+
isMarked={selectedIds?.has(item.task.id)}
|
|
226
|
+
depth={item.depth}
|
|
227
|
+
searchQuery={searchQuery}
|
|
228
|
+
pluginColumns={pluginColumns}
|
|
229
|
+
pluginColumnContextMap={pluginColumnContextMap}
|
|
230
|
+
/>
|
|
231
|
+
</Box>
|
|
232
|
+
);
|
|
233
|
+
});
|
|
234
|
+
})()
|
|
235
|
+
) : (
|
|
236
|
+
visibleTasks.map((item, i) => (
|
|
237
|
+
<TaskRow
|
|
238
|
+
key={item.task.id}
|
|
239
|
+
task={item.task}
|
|
240
|
+
isSelected={scrollStart + i === selectedIndex}
|
|
241
|
+
isMarked={selectedIds?.has(item.task.id)}
|
|
242
|
+
depth={item.depth}
|
|
243
|
+
searchQuery={searchQuery}
|
|
244
|
+
pluginColumns={pluginColumns}
|
|
245
|
+
pluginColumnContextMap={pluginColumnContextMap}
|
|
246
|
+
/>
|
|
247
|
+
))
|
|
248
|
+
)}
|
|
249
|
+
{flatTasks.length > viewHeight && (
|
|
250
|
+
<Box marginTop={1}>
|
|
251
|
+
<Text color="gray" dimColor>
|
|
252
|
+
{scrollStart + 1}-{scrollEnd}/{flatTasks.length}
|
|
253
|
+
</Text>
|
|
254
|
+
</Box>
|
|
255
|
+
)}
|
|
256
|
+
</Box>
|
|
257
|
+
);
|
|
258
|
+
}
|