initial commit

This commit is contained in:
Kacper Marzecki 2025-03-14 20:14:27 +01:00
commit dcc64827fa
10 changed files with 10160 additions and 0 deletions

12
.editorconfig Normal file
View File

@ -0,0 +1,12 @@
root = true
[*]
indent_style = tab
end_of_line = lf
charset = utf-8
trim_trailing_whitespace = true
insert_final_newline = true
[*.yml]
indent_style = space
indent_size = 2

1
.gitattributes vendored Normal file
View File

@ -0,0 +1 @@
* text=auto eol=lf

2
.gitignore vendored Normal file
View File

@ -0,0 +1,2 @@
node_modules
dist

1
.prettierignore Normal file
View File

@ -0,0 +1 @@
dist

9538
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

60
package.json Normal file
View File

@ -0,0 +1,60 @@
{
"name": "td",
"version": "0.0.0",
"license": "MIT",
"bin": "dist/cli.js",
"type": "module",
"engines": {
"node": ">=16"
},
"scripts": {
"build": "babel --out-dir=dist source",
"dev": "babel --out-dir=dist --watch source",
"test": "prettier --check . && xo && ava"
},
"files": [
"dist"
],
"dependencies": {
"fullscreen-ink": "^0.0.2",
"ink": "^5.2.0",
"ink-tab": "^5.1.0",
"meow": "^11.0.0",
"react": "^18.2.0"
},
"devDependencies": {
"@babel/cli": "^7.21.0",
"@babel/preset-react": "^7.18.6",
"@vdemedes/prettier-config": "^2.0.1",
"ava": "^5.2.0",
"chalk": "^5.2.0",
"eslint-config-xo-react": "^0.27.0",
"eslint-plugin-react": "^7.32.2",
"eslint-plugin-react-hooks": "^4.6.0",
"import-jsx": "^5.0.0",
"ink-testing-library": "^3.0.0",
"prettier": "^2.8.7",
"xo": "^0.53.1"
},
"ava": {
"environmentVariables": {
"NODE_NO_WARNINGS": "1"
},
"nodeArguments": [
"--loader=import-jsx"
]
},
"xo": {
"extends": "xo-react",
"prettier": true,
"rules": {
"react/prop-types": "off"
}
},
"prettier": "@vdemedes/prettier-config",
"babel": {
"presets": [
"@babel/preset-react"
]
}
}

52
readme.md Normal file
View File

@ -0,0 +1,52 @@
# td
> This readme is automatically generated by [create-ink-app](https://github.com/vadimdemedes/create-ink-app)
## Install
```bash
$ npm install --global td
```
# Manual
1. **Task Management**:
- Create, edit, delete tasks.
- View lists in a ranger-like UI https://github.com/ranger/ranger
- Tasks can have subtasks.
2. **Navigation**:
- Vim-like keybindings for navigation:
- `hjkl` to navigate in and between subtask lists.
- `HJKL` to move a task in and between subtask lists.
- `g` to go to the top of the list.
- `G` to go to the bottom of the list.
- `d` to go down 5 tasks
- `u` to go up 5 tasks
- `D` to delete a task (with a confirmation dialog)
- `x` to cut a task
- `p` to paste a cut task
3. **Task Operations**:
- `a` to add a new task.
- `A` to add a new subtask.
- `a` to add a new task.
- `e` to edit the selected task using neovim.
- `d` to delete the selected task. (with confirmation using fzf)
- `space` to toggle the completion status of a task.
4. **External Editor**:
- Uses neovim for text editing.
5. **Persistence**:
- Save and load tasks from a file ($HOME/.config/td/tasks.json)
- TODO: configurable location
6. **Search**:
- `/` to search for tasks (fzf).
7. **Help**:
- `?` to show help for keybindings.

411
source/app.js Normal file
View File

@ -0,0 +1,411 @@
import React, { useState, useEffect } from "react";
import { render, Box, Text, useApp, useInput } from "ink";
import fs from "fs";
import path from "path";
import { execSync, spawn } from "child_process";
import { fileURLToPath } from "url";
import { Tabs, Tab } from 'ink-tab';
import Scrollbar from "./scrollbar.js";
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const TASK_FILE = path.join(__dirname, "tasks.json");
const loadTasks = () => {
try {
return ensureIds(JSON.parse(fs.readFileSync(TASK_FILE, "utf8")));
} catch {
return [];
}
};
const ensureIds = (tasks) => {
return tasks.map(task => {
if (!task.id) {
task.id = randomId();
}
if (task.subtasks && task.subtasks.length > 0) {
task.subtasks = ensureIds(task.subtasks);
}
return task;
});
};
const saveTasks = (tasks) => {
const updatedTasks = ensureIds(tasks);
fs.writeFileSync(TASK_FILE, JSON.stringify(updatedTasks, null, 2));
};
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(" ");
setConsole(prev => [...prev, x]);
}
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 'nvim ${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("nvim", [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 === "?") {
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("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.map(it => <Text>{it}</Text>)}
</Box>
);
};

25
source/cli.js Normal file
View File

@ -0,0 +1,25 @@
#!/usr/bin/env node
import React from 'react';
import { render } from 'ink';
import meow from 'meow';
import TaskApp from './app.js';
import { withFullScreen } from "fullscreen-ink";
const cli = meow(
`
Usage
$ td
Options
--name Your name
Examples
$ td --name=Jane
Hello, Jane
`,
{
importMeta: import.meta,
},
);
withFullScreen(<TaskApp />).start();
// render(<TaskApp name={cli.flags.name} />);

58
source/scrollbar.js Normal file
View File

@ -0,0 +1,58 @@
//
// Inspired by https://github.com/karaggeorge/ink-scrollbar
//
import { Text, Box } from 'ink';
import React, { useState, useEffect, useCallback } from 'react';
export default function Scrollbar({ current, thumbCharacter, highlight, padding, show, children }) {
const length = children.length;
const limit = Math.min(show, length);
const maxLength = Math.max(...children.map(option => option.length));
const [viewpoint, setViewpoint] = useState(
Math.min(Math.max(0, current - Math.floor(limit / 2)), length - limit)
);
useEffect(() => {
if (current < viewpoint) {
setViewpoint(current);
} else if (current >= viewpoint + limit) {
setViewpoint(Math.min(current - limit + 1, length - limit));
}
}, [current, viewpoint, limit, length]);
const getScrollbar = useCallback(() => {
const height = Math.max(Math.round((limit / length) * limit), 1);
if (height === limit) return [];
const pos = Math.min(Math.round((viewpoint / length) * limit), limit - 1);
return Array.from({ length: height }, (_, i) => pos + i);
}, [limit, length, viewpoint]);
const getOption = useCallback((option, index) => {
if (!highlight || current !== index + viewpoint) return option;
const props = highlight === true ? { green: true } : highlight;
return <Box {...props}>{option}</Box>;
}, [current, viewpoint, highlight]);
const getSpacing = useCallback((option) => {
return '\u00A0'.repeat(maxLength - option.length + padding);
}, [maxLength, padding]);
const scrollbar = getScrollbar();
return (
children.filter((_, i) => i >= viewpoint && i < viewpoint + limit)
.map((option, i) => (
<Box key={i}>
<Text>{scrollbar.includes(i) ? `\u00A0${thumbCharacter}` : '\u00A0'.repeat(thumbCharacter.length + 1)}</Text>
{getOption(option, i)}
{getSpacing(option)}
</Box>
))
);
};