425 lines
13 KiB
JavaScript
425 lines
13 KiB
JavaScript
|
|
import React, { useState, useEffect } from "react";
|
|
import { render, Box, Text, useApp, useInput } from "ink";
|
|
import fs from "fs";
|
|
import { execSync, spawn } from "child_process";
|
|
import { loadTasks, saveTasks, commitAndPushTasks, pullTasks, addLog, readLog, saveQueryFile } from "./tasks.js";
|
|
import { Tabs, Tab } from 'ink-tab';
|
|
import Scrollbar from "./scrollbar.js";
|
|
|
|
// got it set to `n` - my alias for neovim
|
|
const EDITOR = process.env.TD_EDITOR || "n"
|
|
|
|
const countIncompleteSubtasks = (task) => {
|
|
if (!task.subtasks || task.subtasks.length === 0) return 0;
|
|
return task.subtasks.reduce((count, sub) => count + (!sub.completed ? 1 : 0) + countIncompleteSubtasks(sub), 0);
|
|
};
|
|
|
|
|
|
const fzfLine = content => content ? content.replace(/\n/g, "|") : ""
|
|
const findTaskPathWithContent = (allTasks, content) => {
|
|
for (let i = 0; i < allTasks.length; i++) {
|
|
const task = allTasks[i];
|
|
if (fzfLine(task.content) === content) {
|
|
return [i];
|
|
}
|
|
if (task.subtasks && task.subtasks.length > 0) {
|
|
const subtaskPath = findTaskPathWithContent(task.subtasks, content);
|
|
if (subtaskPath) {
|
|
return [i, ...subtaskPath];
|
|
}
|
|
}
|
|
}
|
|
return null;
|
|
};
|
|
|
|
|
|
const taskContents = (task) => {
|
|
if (!task.subtasks || task.subtasks.length === 0) return [task.content];
|
|
return [task.content].concat(task.subtasks.map(taskContents).flat());
|
|
};
|
|
const randomId = () => Math.random().toString(36).substr(2, 9)
|
|
|
|
const listAt = (tasks, path) => {
|
|
let currentSubtasks = tasks;
|
|
for (let i of path) {
|
|
currentSubtasks = currentSubtasks[i].subtasks;
|
|
}
|
|
return currentSubtasks || []
|
|
}
|
|
|
|
const tasksAt = listAt
|
|
|
|
const last = (list) => list[list.length - 1]
|
|
const allButLast = (l) => {
|
|
return l.slice(0, -1)
|
|
}
|
|
|
|
const setLast = (list, elem) => {
|
|
list[list.length - 1] = elem
|
|
return list
|
|
}
|
|
|
|
const currentSubtasks = (tasks, path) => {
|
|
return tasksAt(tasks, allButLast(path))
|
|
}
|
|
export default function TaskApp() {
|
|
const { exit } = useApp();
|
|
const [tasks, setTasks] = useState(loadTasks());
|
|
const [taskPath, setTaskPath] = useState([0]);
|
|
const [activeTabName, setActiveTabName] = useState(null);
|
|
const [consoleLog, setConsole] = useState("");
|
|
const [cutTask, setCutTask] = useState(null);
|
|
const focusedIdx = last(taskPath)
|
|
const [visibleSubtasks, setVisibleSubtasks] = useState(currentSubtasks(tasks, taskPath));
|
|
useEffect(() => {
|
|
saveTasks(tasks);
|
|
}, [tasks]);
|
|
|
|
useEffect(() => {
|
|
setVisibleSubtasks(currentSubtasks(tasks, taskPath))
|
|
}, [tasks, taskPath])
|
|
const log = (...elems) => {
|
|
let x = elems.map(e => {
|
|
if (typeof e === "object") {
|
|
return JSON.stringify(e);
|
|
}
|
|
return e;
|
|
}).join(" ");
|
|
addLog(x)
|
|
setConsole(readLog() || "");
|
|
}
|
|
const focusTask = (index) => {
|
|
log("Focus task:", index);
|
|
if (index >= 0 && index < visibleSubtasks.length) {
|
|
setTaskPath(prev => {
|
|
const n = [...prev];
|
|
n[n.length - 1] = index;
|
|
return n
|
|
});
|
|
}
|
|
};
|
|
|
|
const moveFocus = (diff) => {
|
|
if (diff > 0) {
|
|
focusTask(Math.min(focusedIdx + diff, visibleSubtasks.length - 1));
|
|
} else {
|
|
focusTask(Math.max(focusedIdx + diff, 0));
|
|
}
|
|
}
|
|
|
|
|
|
const editWithNeovim = (task) => {
|
|
const tempFile = "/tmp/task_edit.txt";
|
|
fs.writeFileSync(tempFile, task.content || task.name);
|
|
try {
|
|
// Run editor in a tmux split and use "wait-for" to track when it closes
|
|
execSync(`tmux split-window -h '${EDITOR} ${tempFile}; tmux wait-for -S editing_done'`);
|
|
|
|
// Wait for editor to exit (blocks execution until the user quits the editor)
|
|
execSync("tmux wait-for editing_done");
|
|
} catch (error) {
|
|
// If tmux is not available, open Neovim normally
|
|
spawn("n", [tempFile], { stdio: "inherit" });
|
|
}
|
|
|
|
const editedTask = fs.readFileSync(tempFile, "utf8").trim();
|
|
log(editedTask)
|
|
task.name = editedTask.split("\n")[0];
|
|
task.content = editedTask;
|
|
return task
|
|
}
|
|
|
|
useInput((input, key) => {
|
|
if (input === "Q") exit();
|
|
if (input === "j") moveFocus(1);
|
|
if (input === "k") moveFocus(-1);
|
|
if (input === "d") moveFocus(5);
|
|
if (input === "u") moveFocus(-5);
|
|
if (input === "g") focusTask(0);
|
|
if (input === "G") focusTask(visibleSubtasks.length - 1);
|
|
if (input === "h" && taskPath.length > 1) {
|
|
setTaskPath(taskPath.slice(0, -1));
|
|
}
|
|
if (input === "l" && visibleSubtasks[focusedIdx]?.subtasks.length > 0) {
|
|
setTaskPath([...taskPath, 0]);
|
|
}
|
|
|
|
if (input === "H" && taskPath.length > 1) {
|
|
let parentPath = allButLast(taskPath);
|
|
let parentTasks = tasksAt(tasks, allButLast(parentPath));
|
|
let currentIdx = last(parentPath);
|
|
|
|
if (currentIdx > -1) {
|
|
let task = parentTasks[currentIdx].subtasks.splice(last(taskPath), 1)[0];
|
|
parentTasks.push(task);
|
|
setTaskPath([...allButLast(parentPath), parentTasks.length - 1]);
|
|
setTasks([...tasks]);
|
|
log(`Moved task out: ${task.name}`);
|
|
}
|
|
}
|
|
|
|
if (input === "L" && focusedIdx > 0) {
|
|
let task = visibleSubtasks[focusedIdx];
|
|
|
|
let newParent = visibleSubtasks[focusedIdx - 1];
|
|
if (newParent) {
|
|
newParent.subtasks.push(task);
|
|
}
|
|
visibleSubtasks.splice(focusedIdx, 1);
|
|
setTasks([...tasks]);
|
|
setTaskPath([...setLast(taskPath, focusedIdx - 1), newParent.subtasks.length - 1]);
|
|
log(`Indented task under: ${newParent.name}`);
|
|
}
|
|
|
|
if (input === "J" && focusedIdx < visibleSubtasks.length - 1) {
|
|
const temp = visibleSubtasks[focusedIdx];
|
|
visibleSubtasks[focusedIdx] = visibleSubtasks[focusedIdx + 1];
|
|
visibleSubtasks[focusedIdx + 1] = temp;
|
|
setTaskPath([...setLast(taskPath, focusedIdx + 1)]);
|
|
setTasks([...tasks]);
|
|
log(`Moved task down: ${visibleSubtasks[focusedIdx].name}`);
|
|
}
|
|
|
|
if (input === "K" && focusedIdx > 0) {
|
|
const temp = visibleSubtasks[focusedIdx];
|
|
log(`Moving task up: ${temp.name}`);
|
|
visibleSubtasks[focusedIdx] = visibleSubtasks[focusedIdx - 1];
|
|
visibleSubtasks[focusedIdx - 1] = temp;
|
|
setTasks([...tasks]);
|
|
setTaskPath([...setLast(taskPath, focusedIdx - 1)]);
|
|
}
|
|
if (input === "a") {
|
|
let newTask = editWithNeovim({ id: randomId(), name: "", completed: false, subtasks: [], content: "" })
|
|
visibleSubtasks.push(newTask);
|
|
setTasks([...tasks]);
|
|
setTaskPath(setLast(taskPath, visibleSubtasks.length - 1))
|
|
}
|
|
if (input === "A") {
|
|
let task = visibleSubtasks[focusedIdx];
|
|
let newTask = editWithNeovim({ id: randomId(), name: "", completed: false, subtasks: [], content: "" })
|
|
task.subtasks.push(newTask);
|
|
setTasks([...tasks]);
|
|
let position = visibleSubtasks[focusedIdx].subtasks.length - 1;
|
|
setTaskPath([...taskPath, position]);
|
|
}
|
|
if (input === " ") {
|
|
let task = visibleSubtasks[focusedIdx];
|
|
task.completed = !task.completed;
|
|
setTasks([...tasks]);
|
|
}
|
|
if (input === "e") {
|
|
let task = visibleSubtasks[focusedIdx];
|
|
task = editWithNeovim(task)
|
|
setTasks([...tasks]);
|
|
}
|
|
if (input === "D") {
|
|
const confirmation = execSync(`echo "Yes\nNo" | fzf --prompt 'Delete task and subtasks? '`, { stdio: "pipe" }).toString().trim();
|
|
if (confirmation === "Yes") {
|
|
visibleSubtasks.splice(focusedIdx, 1)
|
|
setTasks([...tasks]);
|
|
if (visibleSubtasks.length === 0) {
|
|
setTaskPath(taskPath.slice(0, -1))
|
|
} else {
|
|
setTaskPath(setLast(taskPath, focusedIdx - 1))
|
|
}
|
|
}
|
|
}
|
|
|
|
if (input === "s") {
|
|
try {
|
|
const taskNames = tasks.map(taskContents).flat().map(fzfLine).join("\n");
|
|
const queryFile = saveQueryFile(taskNames)
|
|
const query = execSync(`cat ${queryFile} | fzf --prompt 'Search: ' --preview "echo {} | sed 's/|/\\n/g'"`, { stdio: "pipe" }).toString().trim();
|
|
const res = findTaskPathWithContent(tasks, query)
|
|
log("Path:", res)
|
|
if (res) {
|
|
setTaskPath(res);
|
|
setTimeout(() => {
|
|
focusTask(res[res.length - 1]);
|
|
}, 30)
|
|
}
|
|
} catch (error) {
|
|
log("Error searching:", error);
|
|
}
|
|
}
|
|
|
|
if (input === "x" && visibleSubtasks[focusedIdx]) {
|
|
setCutTask({
|
|
task: visibleSubtasks[focusedIdx],
|
|
path: [...taskPath],
|
|
index: focusedIdx
|
|
});
|
|
log(`Cut task: ${visibleSubtasks[focusedIdx].name}`);
|
|
}
|
|
|
|
if (input === "P" && cutTask) {
|
|
// Remove task from original location
|
|
let originalParent = tasksAt(tasks, allButLast(cutTask.path));
|
|
originalParent.splice(cutTask.index, 1);
|
|
|
|
// Add task to the new location
|
|
let targetSubtasks = tasksAt(tasks, taskPath);
|
|
targetSubtasks.push(cutTask.task);
|
|
|
|
setTasks([...tasks]);
|
|
setCutTask(null);
|
|
log(`Moved task: ${cutTask.task.name}`);
|
|
}
|
|
|
|
if (input === "p" && cutTask) {
|
|
// Remove task from original location
|
|
let originalParent = tasksAt(tasks, allButLast(cutTask.path));
|
|
originalParent.splice(cutTask.index, 1);
|
|
|
|
// Add task to the new location
|
|
visibleSubtasks.push(cutTask.task);
|
|
|
|
setTasks([...tasks]);
|
|
setCutTask(null);
|
|
log(`Moved task: ${cutTask.task.name}`);
|
|
}
|
|
|
|
if (input === "S") {
|
|
const confirmation = execSync(`echo "push\npull" | fzf --prompt 'Sync'`, { stdio: "pipe" }).toString().trim();
|
|
if (confirmation === "push") {
|
|
commitAndPushTasks(log)
|
|
}
|
|
|
|
if (confirmation === "pull") {
|
|
pullTasks(log)
|
|
setTasks(loadTasks(), log)
|
|
}
|
|
}
|
|
|
|
|
|
if (input === "\\") {
|
|
try {
|
|
execSync("command -v tmux-td");
|
|
execSync("tmux-td");
|
|
} catch (error) {
|
|
log("tmux-td is not available");
|
|
}
|
|
}
|
|
|
|
|
|
if (input === "1") {
|
|
editWithNeovim({ id: randomId(), name: "", completed: false, subtasks: [], content: consoleLog })
|
|
}
|
|
|
|
if (input === "?") {
|
|
setTimeout(() => {
|
|
editWithNeovim({
|
|
id: randomId(), name: "", completed: false, subtasks: [], content: `
|
|
==========================
|
|
Keybindings:
|
|
j/k - Move up/down
|
|
d/u - Move down/up 5 tasks
|
|
D - remove task
|
|
g/G - Go to top/bottom
|
|
h/l - Navigate parent/subtasks
|
|
x - cut a task
|
|
p - paste the task
|
|
P - paste the task as a subtask
|
|
a - Add task
|
|
A - Add subtask
|
|
e - Edit task in Neovim
|
|
s - Search
|
|
S - Sync (Push/Pull)
|
|
space - Toggle completion
|
|
? - Show help
|
|
==========================
|
|
` })
|
|
|
|
}, 100)
|
|
}
|
|
log("Input:", input);
|
|
});
|
|
const countText = (task) => {
|
|
if (!task.subtasks || task.subtasks.length === 0) return "";
|
|
return `(${countIncompleteSubtasks(task)})`;
|
|
}
|
|
const taskContent = visibleSubtasks[focusedIdx]?.content || "No content available";
|
|
const taskRow = (task, index, onPath) => {
|
|
let cut = task.id === cutTask?.task?.id
|
|
let symbol = cut ? "✂" :
|
|
task.completed ? "✔" : "☐"
|
|
return <>
|
|
<Box flexDirection="row" key={index} overflow="hidden" >
|
|
<Box width={4} overflow="hidden">
|
|
<Text color={onPath ? "green" : "white"}>{onPath ? ">" : '\u00A0'}{symbol}</Text>
|
|
</Box>
|
|
<Box overflow="hidden">
|
|
<Text color={onPath ? "green" : "white"} >{task.name.trim()} {countText(task)}</Text>
|
|
</Box>
|
|
</Box>
|
|
</>
|
|
}
|
|
|
|
const parentIdx = taskPath[taskPath.length - 2]
|
|
return (
|
|
<Box flexDirection="column" height="100%">
|
|
<Box>
|
|
<Tabs onChange={setActiveTabName}>
|
|
<Tab name="tasks">Tasks</Tab>
|
|
<Tab name="console">Console</Tab>
|
|
</Tabs>
|
|
</Box>
|
|
|
|
{activeTabName === 'tasks' && (
|
|
<Box flexDirection="column" >
|
|
<Box flexDirection="row" width="100%" height="75%">
|
|
{/* Left Pane - Parent Task */}
|
|
<Box flexDirection="column" width="25%">
|
|
{taskPath.length > 1 &&
|
|
<Scrollbar thumbCharacter={"|"} show={12} current={parentIdx} highlight={true}>
|
|
{tasksAt(tasks, taskPath.slice(0, taskPath.length - 2)).map((task, index) => (
|
|
taskRow(task, index, index === parentIdx)
|
|
))}
|
|
</Scrollbar>
|
|
}
|
|
</Box>
|
|
|
|
{/* Middle Pane - Visible Subtasks */}
|
|
<Box flexDirection="column" width="45%" overflow="hidden">
|
|
<Scrollbar thumbCharacter={"|"} show={12} current={focusedIdx} highlight={true}>
|
|
{visibleSubtasks.map((task, index) => (
|
|
taskRow(task, index, index === focusedIdx)
|
|
))}
|
|
</Scrollbar>
|
|
</Box>
|
|
|
|
{/* Right Pane - Subtasks of Focused Task */}
|
|
<Box flexDirection="column" width="30%" overflow="hidden">
|
|
{visibleSubtasks[focusedIdx]?.subtasks?.length > 0 && (
|
|
<Text color="cyan">Subtasks:</Text>
|
|
)}
|
|
{visibleSubtasks[focusedIdx]?.subtasks?.map((subtask, index) => (
|
|
taskRow(subtask, index, false)
|
|
))}
|
|
</Box>
|
|
|
|
</Box>
|
|
|
|
{/* Bottom Drawer - Task Content Display */}
|
|
{taskContent && (
|
|
<Box
|
|
width="100%"
|
|
height="25%"
|
|
backgroundColor="black"
|
|
>
|
|
<Text color="white">{taskContent}</Text>
|
|
</Box>
|
|
)}
|
|
<Text>Path: {taskPath.join(" > ") || "Root"}</Text>
|
|
</Box>
|
|
|
|
)}
|
|
|
|
|
|
{activeTabName === 'console' && consoleLog.split("\n").map(it => <Text>{it}</Text>)}
|
|
</Box>
|
|
);
|
|
};
|
|
|