This commit is contained in:
Kacper Marzecki 2025-03-16 21:57:01 +01:00
parent de973a98de
commit c1c2f3c6f0

View File

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