@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,109 @@
|
|
|
1
|
+
import { useState, useEffect, useRef } from "react";
|
|
2
|
+
import { Box, Text, useInput } from "ink";
|
|
3
|
+
import React from "react";
|
|
4
|
+
|
|
5
|
+
interface InputPromptProps {
|
|
6
|
+
prompt: string;
|
|
7
|
+
defaultValue?: string;
|
|
8
|
+
placeholder?: string;
|
|
9
|
+
onSubmit: (value: string) => void;
|
|
10
|
+
onCancel: () => void;
|
|
11
|
+
onCtrlE?: () => void;
|
|
12
|
+
onPreview?: (value: string) => React.ReactNode;
|
|
13
|
+
footer?: React.ReactNode;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function InputPrompt({ prompt, defaultValue = "", placeholder, onSubmit, onCancel, onCtrlE, onPreview, footer }: InputPromptProps) {
|
|
17
|
+
const [value, setValue] = useState(defaultValue);
|
|
18
|
+
const [cursor, setCursor] = useState(defaultValue.length);
|
|
19
|
+
const [flash, setFlash] = useState("");
|
|
20
|
+
const flashTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
21
|
+
useEffect(() => () => { if (flashTimer.current) clearTimeout(flashTimer.current); }, []);
|
|
22
|
+
|
|
23
|
+
useInput((input, key) => {
|
|
24
|
+
if (key.escape) {
|
|
25
|
+
onCancel();
|
|
26
|
+
return;
|
|
27
|
+
}
|
|
28
|
+
if (key.ctrl && input === "e" && onCtrlE) {
|
|
29
|
+
onCtrlE();
|
|
30
|
+
return;
|
|
31
|
+
}
|
|
32
|
+
if (key.return) {
|
|
33
|
+
if (value.trim()) {
|
|
34
|
+
onSubmit(value.trim());
|
|
35
|
+
setValue("");
|
|
36
|
+
setCursor(0);
|
|
37
|
+
setFlash("Created!");
|
|
38
|
+
if (flashTimer.current) clearTimeout(flashTimer.current);
|
|
39
|
+
flashTimer.current = setTimeout(() => setFlash(""), 1500);
|
|
40
|
+
}
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
if (key.backspace || key.delete) {
|
|
44
|
+
if (cursor > 0) {
|
|
45
|
+
setValue((v) => v.slice(0, cursor - 1) + v.slice(cursor));
|
|
46
|
+
setCursor((c) => c - 1);
|
|
47
|
+
}
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
if (key.leftArrow) {
|
|
51
|
+
setCursor((c) => Math.max(0, c - 1));
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
if (key.rightArrow) {
|
|
55
|
+
setCursor((c) => Math.min(value.length, c + 1));
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
// Home / Ctrl-A: move cursor to start
|
|
59
|
+
if ((key.ctrl && input === "a") || (key.meta && key.leftArrow)) {
|
|
60
|
+
setCursor(0);
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
// End / Ctrl-E (when no onCtrlE): move cursor to end
|
|
64
|
+
if ((key.ctrl && input === "e" && !onCtrlE) || (key.meta && key.rightArrow)) {
|
|
65
|
+
setCursor(value.length);
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
if (input && !key.ctrl && !key.meta) {
|
|
69
|
+
setValue((v) => v.slice(0, cursor) + input + v.slice(cursor));
|
|
70
|
+
setCursor((c) => c + input.length);
|
|
71
|
+
}
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
const before = value.slice(0, cursor);
|
|
75
|
+
const cursorChar = value[cursor] ?? " ";
|
|
76
|
+
const after = value.slice(cursor + 1);
|
|
77
|
+
|
|
78
|
+
const showPlaceholder = !value && placeholder;
|
|
79
|
+
|
|
80
|
+
return (
|
|
81
|
+
<Box flexDirection="column" borderStyle="single" borderColor="yellow" paddingX={1}>
|
|
82
|
+
<Box>
|
|
83
|
+
<Text>
|
|
84
|
+
<Text color="yellow">{prompt}: </Text>
|
|
85
|
+
{flash ? <Text color="green" bold>{flash} </Text> : null}
|
|
86
|
+
{showPlaceholder ? (
|
|
87
|
+
<Text dimColor>{placeholder}</Text>
|
|
88
|
+
) : (
|
|
89
|
+
<>
|
|
90
|
+
<Text>{before}</Text>
|
|
91
|
+
<Text backgroundColor="white" color="black">{cursorChar}</Text>
|
|
92
|
+
<Text>{after}</Text>
|
|
93
|
+
</>
|
|
94
|
+
)}
|
|
95
|
+
</Text>
|
|
96
|
+
</Box>
|
|
97
|
+
{onPreview && value.trim() && (
|
|
98
|
+
<Box flexDirection="column" marginTop={1}>
|
|
99
|
+
{onPreview(value)}
|
|
100
|
+
</Box>
|
|
101
|
+
)}
|
|
102
|
+
{footer && (
|
|
103
|
+
<Box marginTop={onPreview && value.trim() ? 0 : 1}>
|
|
104
|
+
{footer}
|
|
105
|
+
</Box>
|
|
106
|
+
)}
|
|
107
|
+
</Box>
|
|
108
|
+
);
|
|
109
|
+
}
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import { useState, useMemo } from "react";
|
|
2
|
+
import { Box, Text, useInput } from "ink";
|
|
3
|
+
import type { Label } from "../../api/types.ts";
|
|
4
|
+
import { todoistColorMap } from "../../utils/colors.ts";
|
|
5
|
+
|
|
6
|
+
interface LabelPickerProps {
|
|
7
|
+
labels: Label[];
|
|
8
|
+
currentLabels: string[];
|
|
9
|
+
onSave: (labels: string[]) => void;
|
|
10
|
+
onCancel: () => void;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function LabelPicker({ labels, currentLabels, onSave, onCancel }: LabelPickerProps) {
|
|
14
|
+
const [filterText, setFilterText] = useState("");
|
|
15
|
+
const [selectedIndex, setSelectedIndex] = useState(0);
|
|
16
|
+
const [checked, setChecked] = useState<Set<string>>(new Set(currentLabels));
|
|
17
|
+
|
|
18
|
+
const filtered = useMemo(() => {
|
|
19
|
+
if (!filterText) return labels;
|
|
20
|
+
const q = filterText.toLowerCase();
|
|
21
|
+
return labels.filter((l) => l.name.toLowerCase().includes(q));
|
|
22
|
+
}, [labels, filterText]);
|
|
23
|
+
|
|
24
|
+
useInput((input, key) => {
|
|
25
|
+
if (key.escape) {
|
|
26
|
+
onCancel();
|
|
27
|
+
return;
|
|
28
|
+
}
|
|
29
|
+
if (key.return) {
|
|
30
|
+
onSave(Array.from(checked));
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
33
|
+
if (key.backspace || key.delete) {
|
|
34
|
+
setFilterText((v) => v.slice(0, -1));
|
|
35
|
+
setSelectedIndex(0);
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
if (input === " ") {
|
|
39
|
+
const label = filtered[selectedIndex];
|
|
40
|
+
if (label) {
|
|
41
|
+
setChecked((prev) => {
|
|
42
|
+
const next = new Set(prev);
|
|
43
|
+
if (next.has(label.name)) {
|
|
44
|
+
next.delete(label.name);
|
|
45
|
+
} else {
|
|
46
|
+
next.add(label.name);
|
|
47
|
+
}
|
|
48
|
+
return next;
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
if (key.upArrow || (input === "k" && key.ctrl)) {
|
|
54
|
+
setSelectedIndex((i) => Math.max(0, i - 1));
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
if (key.downArrow || (input === "j" && key.ctrl)) {
|
|
58
|
+
setSelectedIndex((i) => Math.min(filtered.length - 1, i + 1));
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
if (input && !key.ctrl && !key.meta) {
|
|
62
|
+
setFilterText((v) => v + input);
|
|
63
|
+
setSelectedIndex(0);
|
|
64
|
+
}
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
return (
|
|
68
|
+
<Box
|
|
69
|
+
flexDirection="column"
|
|
70
|
+
borderStyle="single"
|
|
71
|
+
borderColor="magenta"
|
|
72
|
+
paddingX={1}
|
|
73
|
+
width={40}
|
|
74
|
+
>
|
|
75
|
+
<Box marginBottom={1}>
|
|
76
|
+
<Text bold color="magenta">Labels</Text>
|
|
77
|
+
</Box>
|
|
78
|
+
<Box>
|
|
79
|
+
<Text color="yellow">{"> "}</Text>
|
|
80
|
+
<Text>{filterText || ""}</Text>
|
|
81
|
+
<Text backgroundColor="white" color="black">{" "}</Text>
|
|
82
|
+
</Box>
|
|
83
|
+
<Box flexDirection="column" marginTop={1}>
|
|
84
|
+
{filtered.length === 0 ? (
|
|
85
|
+
<Text color="gray">No matching labels</Text>
|
|
86
|
+
) : (
|
|
87
|
+
filtered.slice(0, 15).map((label, i) => {
|
|
88
|
+
const isSelected = i === selectedIndex;
|
|
89
|
+
const isChecked = checked.has(label.name);
|
|
90
|
+
const color = todoistColorMap[label.color] ?? "white";
|
|
91
|
+
return (
|
|
92
|
+
<Box key={label.id}>
|
|
93
|
+
<Text
|
|
94
|
+
backgroundColor={isSelected ? "blue" : undefined}
|
|
95
|
+
color={isSelected ? "white" : undefined}
|
|
96
|
+
>
|
|
97
|
+
<Text>{isChecked ? " [x] " : " [ ] "}</Text>
|
|
98
|
+
<Text color={isSelected ? "white" : color}>@{label.name}</Text>
|
|
99
|
+
</Text>
|
|
100
|
+
</Box>
|
|
101
|
+
);
|
|
102
|
+
})
|
|
103
|
+
)}
|
|
104
|
+
</Box>
|
|
105
|
+
<Box marginTop={1}>
|
|
106
|
+
<Text color="gray" dimColor>[Space] toggle [Enter] save [Esc] cancel</Text>
|
|
107
|
+
</Box>
|
|
108
|
+
</Box>
|
|
109
|
+
);
|
|
110
|
+
}
|
|
@@ -0,0 +1,275 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { Text } from "ink";
|
|
3
|
+
import type { Task, Project, Label, CreateTaskParams, UpdateTaskParams } from "../../api/types.ts";
|
|
4
|
+
import { InputPrompt } from "./InputPrompt.tsx";
|
|
5
|
+
import { ConfirmDialog } from "./ConfirmDialog.tsx";
|
|
6
|
+
import { HelpOverlay } from "./HelpOverlay.tsx";
|
|
7
|
+
import { SortMenu } from "./SortMenu.tsx";
|
|
8
|
+
import type { SortField } from "./SortMenu.tsx";
|
|
9
|
+
import { CommandPalette } from "./CommandPalette.tsx";
|
|
10
|
+
import type { Command } from "./CommandPalette.tsx";
|
|
11
|
+
import { ProjectPicker } from "./ProjectPicker.tsx";
|
|
12
|
+
import { LabelPicker } from "./LabelPicker.tsx";
|
|
13
|
+
import { EditTaskModal } from "./EditTaskModal.tsx";
|
|
14
|
+
import type { ExtensionRegistry } from "../../plugins/types.ts";
|
|
15
|
+
|
|
16
|
+
type Modal = "none" | "add" | "addSubtask" | "edit" | "delete" | "filter" | "search" | "help" | "sort" | "bulkDelete" | "command" | "due" | "deadline" | "move" | "label" | "editFull" | "createFull" | "rename" | "pluginInput" | "createProject" | "createLabel";
|
|
17
|
+
|
|
18
|
+
interface ModalManagerProps {
|
|
19
|
+
modal: Modal;
|
|
20
|
+
setModal: (modal: Modal) => void;
|
|
21
|
+
selectedTask: Task | undefined;
|
|
22
|
+
selectedIds: Set<string>;
|
|
23
|
+
projects: Project[];
|
|
24
|
+
labels: Label[];
|
|
25
|
+
searchQuery: string;
|
|
26
|
+
setSearchQuery: (q: string) => void;
|
|
27
|
+
sortField: SortField;
|
|
28
|
+
sortDirection: "asc" | "desc";
|
|
29
|
+
filterProjectId: string | undefined;
|
|
30
|
+
filterView: string;
|
|
31
|
+
commands: Command[];
|
|
32
|
+
pluginExtensions?: ExtensionRegistry | null;
|
|
33
|
+
// Handlers
|
|
34
|
+
handleAddTask: (input: string) => Promise<void>;
|
|
35
|
+
handleCreateTaskFull: (params: CreateTaskParams) => Promise<void>;
|
|
36
|
+
handleAddSubtask: (input: string) => Promise<void>;
|
|
37
|
+
handleEditTask: (content: string) => Promise<void>;
|
|
38
|
+
handleRenameTask: (content: string) => Promise<void>;
|
|
39
|
+
handleEditTaskFull: (params: UpdateTaskParams) => Promise<void>;
|
|
40
|
+
handleDeleteConfirm: () => Promise<void>;
|
|
41
|
+
handleBulkDeleteConfirm: () => Promise<void>;
|
|
42
|
+
handleSetDueDate: (due: string) => Promise<void>;
|
|
43
|
+
handleSetDeadline: (deadline: string) => Promise<void>;
|
|
44
|
+
handleMoveToProject: (projectId: string) => Promise<void>;
|
|
45
|
+
handleLabelsSave: (labels: string[]) => Promise<void>;
|
|
46
|
+
handleFilterInput: (query: string) => Promise<void>;
|
|
47
|
+
handleSortSelect: (field: SortField) => void;
|
|
48
|
+
handleSearchSubmit: (value: string) => void;
|
|
49
|
+
handleSearchCancel: () => void;
|
|
50
|
+
renderQuickAddPreview: (value: string) => React.ReactNode;
|
|
51
|
+
handleCreateProject: (name: string) => Promise<void>;
|
|
52
|
+
handleCreateLabel: (name: string) => Promise<void>;
|
|
53
|
+
pendingPluginInput?: {
|
|
54
|
+
label: string;
|
|
55
|
+
placeholder?: string;
|
|
56
|
+
formatPreview?: (value: string) => string;
|
|
57
|
+
} | null;
|
|
58
|
+
handlePluginInput?: (value: string) => void;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function ModalManager({
|
|
62
|
+
modal,
|
|
63
|
+
setModal,
|
|
64
|
+
selectedTask,
|
|
65
|
+
selectedIds,
|
|
66
|
+
projects,
|
|
67
|
+
labels,
|
|
68
|
+
searchQuery,
|
|
69
|
+
setSearchQuery,
|
|
70
|
+
sortField,
|
|
71
|
+
sortDirection,
|
|
72
|
+
filterProjectId,
|
|
73
|
+
filterView,
|
|
74
|
+
commands,
|
|
75
|
+
pluginExtensions,
|
|
76
|
+
handleAddTask,
|
|
77
|
+
handleCreateTaskFull,
|
|
78
|
+
handleAddSubtask,
|
|
79
|
+
handleEditTask,
|
|
80
|
+
handleRenameTask,
|
|
81
|
+
handleEditTaskFull,
|
|
82
|
+
handleDeleteConfirm,
|
|
83
|
+
handleBulkDeleteConfirm,
|
|
84
|
+
handleSetDueDate,
|
|
85
|
+
handleSetDeadline,
|
|
86
|
+
handleMoveToProject,
|
|
87
|
+
handleLabelsSave,
|
|
88
|
+
handleFilterInput,
|
|
89
|
+
handleSortSelect,
|
|
90
|
+
handleSearchSubmit,
|
|
91
|
+
handleSearchCancel,
|
|
92
|
+
handleCreateProject,
|
|
93
|
+
handleCreateLabel,
|
|
94
|
+
renderQuickAddPreview,
|
|
95
|
+
pendingPluginInput,
|
|
96
|
+
handlePluginInput,
|
|
97
|
+
}: ModalManagerProps) {
|
|
98
|
+
return (
|
|
99
|
+
<>
|
|
100
|
+
{modal === "add" && (
|
|
101
|
+
<InputPrompt
|
|
102
|
+
prompt="New task"
|
|
103
|
+
placeholder="Buy milk tomorrow #Shopping p1 @errands"
|
|
104
|
+
onSubmit={handleAddTask}
|
|
105
|
+
onCancel={() => setModal("none")}
|
|
106
|
+
onCtrlE={() => setModal("createFull")}
|
|
107
|
+
onPreview={renderQuickAddPreview}
|
|
108
|
+
footer={
|
|
109
|
+
<Text color="gray" dimColor>
|
|
110
|
+
[Enter] create & continue [Ctrl-E] full editor [Esc] close
|
|
111
|
+
</Text>
|
|
112
|
+
}
|
|
113
|
+
/>
|
|
114
|
+
)}
|
|
115
|
+
{modal === "createFull" && (
|
|
116
|
+
<EditTaskModal
|
|
117
|
+
projects={projects}
|
|
118
|
+
labels={labels}
|
|
119
|
+
onSave={() => {}}
|
|
120
|
+
onCreate={handleCreateTaskFull}
|
|
121
|
+
onCancel={() => setModal("none")}
|
|
122
|
+
defaultProjectId={filterProjectId}
|
|
123
|
+
defaultDue={filterView === "Today" ? "today" : undefined}
|
|
124
|
+
/>
|
|
125
|
+
)}
|
|
126
|
+
{modal === "addSubtask" && selectedTask && (
|
|
127
|
+
<InputPrompt
|
|
128
|
+
prompt={`Subtask of "${selectedTask.content}"`}
|
|
129
|
+
onSubmit={handleAddSubtask}
|
|
130
|
+
onCancel={() => setModal("none")}
|
|
131
|
+
/>
|
|
132
|
+
)}
|
|
133
|
+
{modal === "edit" && selectedTask && (
|
|
134
|
+
<InputPrompt
|
|
135
|
+
prompt="Edit task"
|
|
136
|
+
defaultValue={selectedTask.content}
|
|
137
|
+
onSubmit={handleEditTask}
|
|
138
|
+
onCancel={() => setModal("none")}
|
|
139
|
+
/>
|
|
140
|
+
)}
|
|
141
|
+
{modal === "rename" && selectedTask && (
|
|
142
|
+
<InputPrompt
|
|
143
|
+
prompt="Rename"
|
|
144
|
+
defaultValue={selectedTask.content}
|
|
145
|
+
onSubmit={handleRenameTask}
|
|
146
|
+
onCancel={() => setModal("none")}
|
|
147
|
+
/>
|
|
148
|
+
)}
|
|
149
|
+
{modal === "editFull" && selectedTask && (
|
|
150
|
+
<EditTaskModal
|
|
151
|
+
task={selectedTask}
|
|
152
|
+
projects={projects}
|
|
153
|
+
labels={labels}
|
|
154
|
+
onSave={handleEditTaskFull}
|
|
155
|
+
onCancel={() => setModal("none")}
|
|
156
|
+
/>
|
|
157
|
+
)}
|
|
158
|
+
{modal === "due" && (
|
|
159
|
+
<InputPrompt
|
|
160
|
+
prompt="Due date"
|
|
161
|
+
onSubmit={handleSetDueDate}
|
|
162
|
+
onCancel={() => setModal("none")}
|
|
163
|
+
/>
|
|
164
|
+
)}
|
|
165
|
+
{modal === "deadline" && (
|
|
166
|
+
<InputPrompt
|
|
167
|
+
prompt="Deadline (YYYY-MM-DD)"
|
|
168
|
+
onSubmit={handleSetDeadline}
|
|
169
|
+
onCancel={() => setModal("none")}
|
|
170
|
+
/>
|
|
171
|
+
)}
|
|
172
|
+
{modal === "move" && (
|
|
173
|
+
<ProjectPicker
|
|
174
|
+
projects={projects}
|
|
175
|
+
onSelect={handleMoveToProject}
|
|
176
|
+
onCancel={() => setModal("none")}
|
|
177
|
+
/>
|
|
178
|
+
)}
|
|
179
|
+
{modal === "label" && (selectedTask || selectedIds.size > 0) && (
|
|
180
|
+
<LabelPicker
|
|
181
|
+
labels={labels}
|
|
182
|
+
currentLabels={selectedTask?.labels ?? []}
|
|
183
|
+
onSave={handleLabelsSave}
|
|
184
|
+
onCancel={() => setModal("none")}
|
|
185
|
+
/>
|
|
186
|
+
)}
|
|
187
|
+
{modal === "delete" && selectedTask && (
|
|
188
|
+
<ConfirmDialog
|
|
189
|
+
message={`Delete "${selectedTask.content}"?`}
|
|
190
|
+
onConfirm={handleDeleteConfirm}
|
|
191
|
+
onCancel={() => setModal("none")}
|
|
192
|
+
/>
|
|
193
|
+
)}
|
|
194
|
+
{modal === "bulkDelete" && (
|
|
195
|
+
<ConfirmDialog
|
|
196
|
+
message={`Delete ${selectedIds.size} selected tasks?`}
|
|
197
|
+
onConfirm={handleBulkDeleteConfirm}
|
|
198
|
+
onCancel={() => setModal("none")}
|
|
199
|
+
/>
|
|
200
|
+
)}
|
|
201
|
+
{modal === "filter" && (
|
|
202
|
+
<InputPrompt
|
|
203
|
+
prompt="Filter"
|
|
204
|
+
onSubmit={handleFilterInput}
|
|
205
|
+
onCancel={() => setModal("none")}
|
|
206
|
+
/>
|
|
207
|
+
)}
|
|
208
|
+
{modal === "search" && (
|
|
209
|
+
<InputPrompt
|
|
210
|
+
prompt="Search"
|
|
211
|
+
defaultValue={searchQuery}
|
|
212
|
+
onSubmit={(val) => {
|
|
213
|
+
setSearchQuery(val);
|
|
214
|
+
handleSearchSubmit(val);
|
|
215
|
+
}}
|
|
216
|
+
onCancel={handleSearchCancel}
|
|
217
|
+
/>
|
|
218
|
+
)}
|
|
219
|
+
{modal === "help" && (
|
|
220
|
+
<HelpOverlay onClose={() => setModal("none")} pluginKeybindings={pluginExtensions?.getKeybindings()} />
|
|
221
|
+
)}
|
|
222
|
+
{modal === "sort" && (
|
|
223
|
+
<SortMenu
|
|
224
|
+
currentSort={sortField}
|
|
225
|
+
currentDirection={sortDirection}
|
|
226
|
+
onSelect={handleSortSelect}
|
|
227
|
+
onCancel={() => setModal("none")}
|
|
228
|
+
/>
|
|
229
|
+
)}
|
|
230
|
+
{modal === "pluginInput" && pendingPluginInput && handlePluginInput && (
|
|
231
|
+
<InputPrompt
|
|
232
|
+
prompt={pendingPluginInput.label}
|
|
233
|
+
placeholder={pendingPluginInput.placeholder}
|
|
234
|
+
onSubmit={(val) => {
|
|
235
|
+
handlePluginInput(val);
|
|
236
|
+
}}
|
|
237
|
+
onCancel={() => setModal("none")}
|
|
238
|
+
onPreview={pendingPluginInput.formatPreview ? (val: string) => {
|
|
239
|
+
const preview = pendingPluginInput.formatPreview!(val);
|
|
240
|
+
return <Text color={preview.startsWith("Invalid") || preview.startsWith("!") ? "red" : "green"}>{preview}</Text>;
|
|
241
|
+
} : undefined}
|
|
242
|
+
footer={
|
|
243
|
+
<Text color="gray" dimColor>
|
|
244
|
+
[Enter] confirm [Esc] cancel
|
|
245
|
+
</Text>
|
|
246
|
+
}
|
|
247
|
+
/>
|
|
248
|
+
)}
|
|
249
|
+
{modal === "createProject" && (
|
|
250
|
+
<InputPrompt
|
|
251
|
+
prompt="New project name"
|
|
252
|
+
placeholder="My Project"
|
|
253
|
+
onSubmit={handleCreateProject}
|
|
254
|
+
onCancel={() => setModal("none")}
|
|
255
|
+
/>
|
|
256
|
+
)}
|
|
257
|
+
{modal === "createLabel" && (
|
|
258
|
+
<InputPrompt
|
|
259
|
+
prompt="New label name"
|
|
260
|
+
placeholder="my-label"
|
|
261
|
+
onSubmit={handleCreateLabel}
|
|
262
|
+
onCancel={() => setModal("none")}
|
|
263
|
+
/>
|
|
264
|
+
)}
|
|
265
|
+
{modal === "command" && (
|
|
266
|
+
<CommandPalette
|
|
267
|
+
commands={commands}
|
|
268
|
+
onCancel={() => setModal("none")}
|
|
269
|
+
/>
|
|
270
|
+
)}
|
|
271
|
+
</>
|
|
272
|
+
);
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
export type { Modal };
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { useState, useMemo } from "react";
|
|
2
|
+
import { Box, Text, useInput } from "ink";
|
|
3
|
+
import type { Project } from "../../api/types.ts";
|
|
4
|
+
import { todoistColorMap } from "../../utils/colors.ts";
|
|
5
|
+
|
|
6
|
+
interface ProjectPickerProps {
|
|
7
|
+
projects: Project[];
|
|
8
|
+
onSelect: (projectId: string) => void;
|
|
9
|
+
onCancel: () => void;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function ProjectPicker({ projects, onSelect, onCancel }: ProjectPickerProps) {
|
|
13
|
+
const [filterText, setFilterText] = useState("");
|
|
14
|
+
const [selectedIndex, setSelectedIndex] = useState(0);
|
|
15
|
+
|
|
16
|
+
const filtered = useMemo(() => {
|
|
17
|
+
if (!filterText) return projects;
|
|
18
|
+
const q = filterText.toLowerCase();
|
|
19
|
+
return projects.filter((p) => p.name.toLowerCase().includes(q));
|
|
20
|
+
}, [projects, filterText]);
|
|
21
|
+
|
|
22
|
+
useInput((input, key) => {
|
|
23
|
+
if (key.escape) {
|
|
24
|
+
onCancel();
|
|
25
|
+
return;
|
|
26
|
+
}
|
|
27
|
+
if (key.return) {
|
|
28
|
+
const project = filtered[selectedIndex];
|
|
29
|
+
if (project) {
|
|
30
|
+
onSelect(project.id);
|
|
31
|
+
}
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
if (key.backspace || key.delete) {
|
|
35
|
+
setFilterText((v) => v.slice(0, -1));
|
|
36
|
+
setSelectedIndex(0);
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
if (key.upArrow || (input === "k" && key.ctrl)) {
|
|
40
|
+
setSelectedIndex((i) => Math.max(0, i - 1));
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
if (key.downArrow || (input === "j" && key.ctrl)) {
|
|
44
|
+
setSelectedIndex((i) => Math.min(filtered.length - 1, i + 1));
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
if (input && !key.ctrl && !key.meta) {
|
|
48
|
+
setFilterText((v) => v + input);
|
|
49
|
+
setSelectedIndex(0);
|
|
50
|
+
}
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
return (
|
|
54
|
+
<Box
|
|
55
|
+
flexDirection="column"
|
|
56
|
+
borderStyle="single"
|
|
57
|
+
borderColor="cyan"
|
|
58
|
+
paddingX={1}
|
|
59
|
+
width={40}
|
|
60
|
+
>
|
|
61
|
+
<Box marginBottom={1}>
|
|
62
|
+
<Text bold color="cyan">Move to project</Text>
|
|
63
|
+
</Box>
|
|
64
|
+
<Box>
|
|
65
|
+
<Text color="yellow">{"> "}</Text>
|
|
66
|
+
<Text>{filterText || ""}</Text>
|
|
67
|
+
<Text backgroundColor="white" color="black">{" "}</Text>
|
|
68
|
+
</Box>
|
|
69
|
+
<Box flexDirection="column" marginTop={1}>
|
|
70
|
+
{filtered.length === 0 ? (
|
|
71
|
+
<Text color="gray">No matching projects</Text>
|
|
72
|
+
) : (
|
|
73
|
+
filtered.slice(0, 15).map((project, i) => {
|
|
74
|
+
const isSelected = i === selectedIndex;
|
|
75
|
+
const color = todoistColorMap[project.color] ?? "white";
|
|
76
|
+
return (
|
|
77
|
+
<Box key={project.id}>
|
|
78
|
+
<Text
|
|
79
|
+
backgroundColor={isSelected ? "blue" : undefined}
|
|
80
|
+
color={isSelected ? "white" : undefined}
|
|
81
|
+
>
|
|
82
|
+
<Text color={isSelected ? "white" : color}>{" "}{project.is_inbox_project ? "> " : " "}</Text>
|
|
83
|
+
<Text>{project.name}</Text>
|
|
84
|
+
</Text>
|
|
85
|
+
</Box>
|
|
86
|
+
);
|
|
87
|
+
})
|
|
88
|
+
)}
|
|
89
|
+
</Box>
|
|
90
|
+
<Box marginTop={1}>
|
|
91
|
+
<Text color="gray" dimColor>[Enter] select [Esc] cancel</Text>
|
|
92
|
+
</Box>
|
|
93
|
+
</Box>
|
|
94
|
+
);
|
|
95
|
+
}
|