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 } from "./tasks.js"; import { Tabs, Tab } from 'ink-tab'; import Scrollbar from "./scrollbar.js"; 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 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'`); // Wait for Neovim to exit (blocks execution until the user quits Neovim) execSync("tmux wait-for nvim_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 === "/") { try { const taskNames = tasks.map(taskContents).flat().map(fzfLine).join("\n"); const query = execSync(`echo "${taskNames}" | fzf --prompt 'Search: '`, { 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 === "?") { 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 <> {onPath ? ">" : '\u00A0'}{symbol} {task.name.trim()} {countText(task)} } const parentIdx = taskPath[taskPath.length - 2] return ( Tasks Console {activeTabName === 'tasks' && ( {/* Left Pane - Parent Task */} {taskPath.length > 1 && {tasksAt(tasks, taskPath.slice(0, taskPath.length - 2)).map((task, index) => ( taskRow(task, index, index === parentIdx) ))} } {/* Middle Pane - Visible Subtasks */} {visibleSubtasks.map((task, index) => ( taskRow(task, index, index === focusedIdx) ))} {/* Right Pane - Subtasks of Focused Task */} {visibleSubtasks[focusedIdx]?.subtasks?.length > 0 && ( Subtasks: )} {visibleSubtasks[focusedIdx]?.subtasks?.map((subtask, index) => ( taskRow(subtask, index, false) ))} {/* Bottom Drawer - Task Content Display */} {taskContent && ( {taskContent} )} Path: {taskPath.join(" > ") || "Root"} )} {activeTabName === 'console' && consoleLog.split("\n").map(it => {it})} ); };