initial commit
This commit is contained in:
commit
dcc64827fa
12
.editorconfig
Normal file
12
.editorconfig
Normal 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
1
.gitattributes
vendored
Normal file
@ -0,0 +1 @@
|
|||||||
|
* text=auto eol=lf
|
||||||
2
.gitignore
vendored
Normal file
2
.gitignore
vendored
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
node_modules
|
||||||
|
dist
|
||||||
1
.prettierignore
Normal file
1
.prettierignore
Normal file
@ -0,0 +1 @@
|
|||||||
|
dist
|
||||||
9538
package-lock.json
generated
Normal file
9538
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
60
package.json
Normal file
60
package.json
Normal 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
52
readme.md
Normal 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
411
source/app.js
Normal 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
25
source/cli.js
Normal 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
58
source/scrollbar.js
Normal 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>
|
||||||
|
))
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
Loading…
x
Reference in New Issue
Block a user