td/source/app.js
Kacper Marzecki 24286a563f log to file
2025-03-16 22:38:44 +01:00

399 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 } 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 <>
<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>
);
};