@hasna/terminal 0.1.4 → 0.1.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/App.js +217 -105
- package/dist/Browse.js +79 -0
- package/dist/FuzzyPicker.js +47 -0
- package/dist/StatusBar.js +20 -16
- package/package.json +1 -1
- package/src/App.tsx +371 -245
- package/src/Browse.tsx +103 -0
- package/src/FuzzyPicker.tsx +69 -0
- package/src/StatusBar.tsx +28 -34
package/src/Browse.tsx
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import React, { useState, useCallback } from "react";
|
|
2
|
+
import { Box, Text, useInput } from "ink";
|
|
3
|
+
import { readdirSync, statSync } from "fs";
|
|
4
|
+
import { join, dirname } from "path";
|
|
5
|
+
|
|
6
|
+
interface BrowseProps {
|
|
7
|
+
cwd: string;
|
|
8
|
+
onCd: (path: string) => void;
|
|
9
|
+
onSelect: (path: string) => void;
|
|
10
|
+
onExit: () => void;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
interface Entry {
|
|
14
|
+
name: string;
|
|
15
|
+
isDir: boolean;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function readDir(dir: string): Entry[] {
|
|
19
|
+
try {
|
|
20
|
+
const names = readdirSync(dir);
|
|
21
|
+
const entries: Entry[] = [];
|
|
22
|
+
for (const name of names) {
|
|
23
|
+
try {
|
|
24
|
+
const stat = statSync(join(dir, name));
|
|
25
|
+
entries.push({ name, isDir: stat.isDirectory() });
|
|
26
|
+
} catch {
|
|
27
|
+
entries.push({ name, isDir: false });
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
entries.sort((a, b) => {
|
|
31
|
+
if (a.isDir !== b.isDir) return a.isDir ? -1 : 1;
|
|
32
|
+
return a.name.localeCompare(b.name);
|
|
33
|
+
});
|
|
34
|
+
return entries;
|
|
35
|
+
} catch {
|
|
36
|
+
return [];
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const PAGE = 20;
|
|
41
|
+
|
|
42
|
+
export default function Browse({ cwd, onCd, onSelect, onExit }: BrowseProps) {
|
|
43
|
+
const [cursor, setCursor] = useState(0);
|
|
44
|
+
|
|
45
|
+
const entries = readDir(cwd);
|
|
46
|
+
const total = entries.length;
|
|
47
|
+
|
|
48
|
+
const safeIndex = Math.min(cursor, Math.max(0, total - 1));
|
|
49
|
+
|
|
50
|
+
const start = Math.max(0, Math.min(safeIndex - Math.floor(PAGE / 2), total - PAGE));
|
|
51
|
+
const slice = entries.slice(Math.max(0, start), Math.max(0, start) + PAGE);
|
|
52
|
+
|
|
53
|
+
useInput(useCallback((_input: string, key: any) => {
|
|
54
|
+
if (key.upArrow) {
|
|
55
|
+
setCursor(c => (c <= 0 ? Math.max(0, total - 1) : c - 1));
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
if (key.downArrow) {
|
|
59
|
+
setCursor(c => (total === 0 ? 0 : (c >= total - 1 ? 0 : c + 1)));
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
if (key.return) {
|
|
63
|
+
if (total === 0) return;
|
|
64
|
+
const entry = entries[safeIndex];
|
|
65
|
+
if (!entry) return;
|
|
66
|
+
const full = join(cwd, entry.name);
|
|
67
|
+
if (entry.isDir) {
|
|
68
|
+
setCursor(0);
|
|
69
|
+
onCd(full);
|
|
70
|
+
} else {
|
|
71
|
+
onSelect(full);
|
|
72
|
+
}
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
if (key.backspace || key.delete || key.leftArrow) {
|
|
76
|
+
setCursor(0);
|
|
77
|
+
onCd(dirname(cwd));
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
if (key.escape) {
|
|
81
|
+
onExit();
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
}, [cwd, entries, safeIndex, total, onCd, onSelect, onExit]));
|
|
85
|
+
|
|
86
|
+
return (
|
|
87
|
+
<Box flexDirection="column">
|
|
88
|
+
<Text dimColor>{cwd}</Text>
|
|
89
|
+
{total === 0 && <Text dimColor> (empty)</Text>}
|
|
90
|
+
{slice.map((entry, i) => {
|
|
91
|
+
const absIdx = Math.max(0, start) + i;
|
|
92
|
+
const selected = absIdx === safeIndex;
|
|
93
|
+
const icon = entry.isDir ? "▸" : "·";
|
|
94
|
+
return (
|
|
95
|
+
<Box key={entry.name}>
|
|
96
|
+
<Text inverse={selected}>{` ${icon} ${entry.name}${entry.isDir ? "/" : ""} `}</Text>
|
|
97
|
+
</Box>
|
|
98
|
+
);
|
|
99
|
+
})}
|
|
100
|
+
<Text dimColor>{" enter backspace esc"}</Text>
|
|
101
|
+
</Box>
|
|
102
|
+
);
|
|
103
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import React, { useState, useCallback } from "react";
|
|
2
|
+
import { Box, Text, useInput } from "ink";
|
|
3
|
+
|
|
4
|
+
interface FuzzyPickerProps {
|
|
5
|
+
history: string[];
|
|
6
|
+
onSelect: (nl: string) => void;
|
|
7
|
+
onExit: () => void;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
const MAX_MATCHES = 10;
|
|
11
|
+
|
|
12
|
+
export default function FuzzyPicker({ history, onSelect, onExit }: FuzzyPickerProps) {
|
|
13
|
+
const [query, setQuery] = useState("");
|
|
14
|
+
const [cursor, setCursor] = useState(0);
|
|
15
|
+
|
|
16
|
+
const matches = query === ""
|
|
17
|
+
? history.slice().reverse().slice(0, MAX_MATCHES)
|
|
18
|
+
: history.slice().reverse().filter(h => h.toLowerCase().includes(query.toLowerCase())).slice(0, MAX_MATCHES);
|
|
19
|
+
|
|
20
|
+
const safeCursor = Math.min(cursor, Math.max(0, matches.length - 1));
|
|
21
|
+
|
|
22
|
+
useInput(useCallback((_input: string, key: any) => {
|
|
23
|
+
if (key.escape || key.ctrl && _input === "c") {
|
|
24
|
+
onExit();
|
|
25
|
+
return;
|
|
26
|
+
}
|
|
27
|
+
if (key.return) {
|
|
28
|
+
if (matches.length > 0) {
|
|
29
|
+
onSelect(matches[safeCursor]);
|
|
30
|
+
}
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
33
|
+
if (key.upArrow) {
|
|
34
|
+
setCursor(c => Math.max(0, c - 1));
|
|
35
|
+
return;
|
|
36
|
+
}
|
|
37
|
+
if (key.downArrow) {
|
|
38
|
+
setCursor(c => Math.min(matches.length - 1, c + 1));
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
if (key.backspace || key.delete) {
|
|
42
|
+
setQuery(q => q.slice(0, -1));
|
|
43
|
+
setCursor(0);
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
if (!key.ctrl && !key.meta && _input && _input.length === 1) {
|
|
47
|
+
setQuery(q => q + _input);
|
|
48
|
+
setCursor(0);
|
|
49
|
+
}
|
|
50
|
+
}, [matches, safeCursor, onSelect, onExit]));
|
|
51
|
+
|
|
52
|
+
return (
|
|
53
|
+
<Box flexDirection="column">
|
|
54
|
+
<Text>{` / ${query}_`}</Text>
|
|
55
|
+
{matches.length === 0
|
|
56
|
+
? <Text dimColor>{" no matches"}</Text>
|
|
57
|
+
: matches.map((m, i) => {
|
|
58
|
+
const selected = i === safeCursor;
|
|
59
|
+
return (
|
|
60
|
+
<Box key={i}>
|
|
61
|
+
<Text inverse={selected} dimColor={!selected}>{` ${m} `}</Text>
|
|
62
|
+
</Box>
|
|
63
|
+
);
|
|
64
|
+
})
|
|
65
|
+
}
|
|
66
|
+
<Text dimColor>{" enter esc"}</Text>
|
|
67
|
+
</Box>
|
|
68
|
+
);
|
|
69
|
+
}
|
package/src/StatusBar.tsx
CHANGED
|
@@ -4,64 +4,58 @@ import { execSync } from "child_process";
|
|
|
4
4
|
import { homedir } from "os";
|
|
5
5
|
import { type Permissions } from "./history.js";
|
|
6
6
|
|
|
7
|
-
function
|
|
8
|
-
const cwd = process.cwd();
|
|
7
|
+
function formatCwd(cwd: string): string {
|
|
9
8
|
const home = homedir();
|
|
10
9
|
return cwd.startsWith(home) ? "~" + cwd.slice(home.length) : cwd;
|
|
11
10
|
}
|
|
12
11
|
|
|
13
|
-
function getGitBranch(): string | null {
|
|
12
|
+
function getGitBranch(cwd: string): string | null {
|
|
14
13
|
try {
|
|
15
|
-
return execSync("git branch --show-current 2>/dev/null", {
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
}
|
|
14
|
+
return execSync("git branch --show-current 2>/dev/null", {
|
|
15
|
+
cwd,
|
|
16
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
17
|
+
}).toString().trim() || null;
|
|
18
|
+
} catch { return null; }
|
|
21
19
|
}
|
|
22
20
|
|
|
23
|
-
function getGitDirty(): boolean {
|
|
21
|
+
function getGitDirty(cwd: string): boolean {
|
|
24
22
|
try {
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
}
|
|
23
|
+
return execSync("git status --porcelain 2>/dev/null", {
|
|
24
|
+
cwd,
|
|
25
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
26
|
+
}).toString().trim().length > 0;
|
|
27
|
+
} catch { return false; }
|
|
30
28
|
}
|
|
31
29
|
|
|
32
30
|
function activePerms(perms: Permissions): string[] {
|
|
33
31
|
const labels: Array<[keyof Permissions, string]> = [
|
|
34
32
|
["destructive", "del"],
|
|
35
|
-
["network",
|
|
36
|
-
["sudo",
|
|
37
|
-
["install",
|
|
33
|
+
["network", "net"],
|
|
34
|
+
["sudo", "sudo"],
|
|
35
|
+
["install", "pkg"],
|
|
38
36
|
["write_outside_cwd", "write"],
|
|
39
37
|
];
|
|
40
|
-
|
|
38
|
+
// only show disabled ones — full access is the default so no need to clutter
|
|
39
|
+
const disabled = labels.filter(([k]) => !perms[k]).map(([, l]) => `no-${l}`);
|
|
40
|
+
return disabled;
|
|
41
41
|
}
|
|
42
42
|
|
|
43
43
|
interface Props {
|
|
44
44
|
permissions: Permissions;
|
|
45
|
+
cwd?: string;
|
|
45
46
|
}
|
|
46
47
|
|
|
47
|
-
export default function StatusBar({ permissions }: Props) {
|
|
48
|
-
const cwd =
|
|
49
|
-
const branch = getGitBranch();
|
|
50
|
-
const dirty = branch ? getGitDirty() : false;
|
|
51
|
-
const
|
|
48
|
+
export default function StatusBar({ permissions, cwd: cwdProp }: Props) {
|
|
49
|
+
const cwd = cwdProp ?? process.cwd();
|
|
50
|
+
const branch = getGitBranch(cwd);
|
|
51
|
+
const dirty = branch ? getGitDirty(cwd) : false;
|
|
52
|
+
const restricted = activePerms(permissions);
|
|
52
53
|
|
|
53
54
|
return (
|
|
54
55
|
<Box gap={2} paddingLeft={2} marginTop={1}>
|
|
55
|
-
<Text dimColor>{cwd}</Text>
|
|
56
|
-
{branch &&
|
|
57
|
-
|
|
58
|
-
{branch}
|
|
59
|
-
{dirty ? " ●" : ""}
|
|
60
|
-
</Text>
|
|
61
|
-
)}
|
|
62
|
-
{perms.length > 0 && (
|
|
63
|
-
<Text dimColor>{perms.join(" · ")}</Text>
|
|
64
|
-
)}
|
|
56
|
+
<Text dimColor>{formatCwd(cwd)}</Text>
|
|
57
|
+
{branch && <Text dimColor>{branch}{dirty ? " ●" : ""}</Text>}
|
|
58
|
+
{restricted.length > 0 && <Text dimColor>{restricted.join(" · ")}</Text>}
|
|
65
59
|
</Box>
|
|
66
60
|
);
|
|
67
61
|
}
|