@a9s/cli 0.0.1 → 0.1.0
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/README.md +167 -2
- package/dist/scripts/seed.js +310 -0
- package/dist/src/App.js +476 -0
- package/dist/src/adapters/ServiceAdapter.js +1 -0
- package/dist/src/adapters/capabilities/ActionCapability.js +1 -0
- package/dist/src/adapters/capabilities/DetailCapability.js +1 -0
- package/dist/src/adapters/capabilities/EditCapability.js +1 -0
- package/dist/src/adapters/capabilities/YankCapability.js +42 -0
- package/dist/src/adapters/capabilities/YankCapability.test.js +29 -0
- package/dist/src/components/AdvancedTextInput.js +200 -0
- package/dist/src/components/AdvancedTextInput.test.js +190 -0
- package/dist/src/components/AutocompleteInput.js +29 -0
- package/dist/src/components/DetailPanel.js +12 -0
- package/dist/src/components/DiffViewer.js +17 -0
- package/dist/src/components/ErrorStatePanel.js +5 -0
- package/dist/src/components/HUD.js +31 -0
- package/dist/src/components/HelpPanel.js +33 -0
- package/dist/src/components/ModeBar.js +43 -0
- package/dist/src/components/Table/index.js +109 -0
- package/dist/src/components/Table/widths.js +19 -0
- package/dist/src/components/TableSkeleton.js +25 -0
- package/dist/src/components/YankHelpPanel.js +43 -0
- package/dist/src/constants/commands.js +15 -0
- package/dist/src/constants/keybindings.js +530 -0
- package/dist/src/constants/keys.js +37 -0
- package/dist/src/features/AppMainView.integration.test.js +133 -0
- package/dist/src/features/AppMainView.js +95 -0
- package/dist/src/hooks/inputEvents.js +1 -0
- package/dist/src/hooks/mainInputScopes.js +68 -0
- package/dist/src/hooks/mainInputScopes.test.js +24 -0
- package/dist/src/hooks/useActionController.js +78 -0
- package/dist/src/hooks/useAppController.js +102 -0
- package/dist/src/hooks/useAppController.test.js +54 -0
- package/dist/src/hooks/useAppData.js +48 -0
- package/dist/src/hooks/useAwsContext.js +77 -0
- package/dist/src/hooks/useAwsProfiles.js +53 -0
- package/dist/src/hooks/useAwsRegions.js +105 -0
- package/dist/src/hooks/useCommandRouter.js +56 -0
- package/dist/src/hooks/useCommandRouter.test.js +27 -0
- package/dist/src/hooks/useDetailController.js +57 -0
- package/dist/src/hooks/useDetailController.test.js +32 -0
- package/dist/src/hooks/useHelpPanel.js +65 -0
- package/dist/src/hooks/useHierarchyState.js +39 -0
- package/dist/src/hooks/useInputEventProcessor.js +450 -0
- package/dist/src/hooks/useInputEventProcessor.test.js +174 -0
- package/dist/src/hooks/useKeyChord.js +83 -0
- package/dist/src/hooks/useMainInput.js +18 -0
- package/dist/src/hooks/useNavigation.js +47 -0
- package/dist/src/hooks/usePendingAction.js +8 -0
- package/dist/src/hooks/usePickerManager.js +130 -0
- package/dist/src/hooks/usePickerState.js +47 -0
- package/dist/src/hooks/usePickerTable.js +20 -0
- package/dist/src/hooks/useServiceView.js +226 -0
- package/dist/src/hooks/useUiHints.js +60 -0
- package/dist/src/hooks/useYankMode.js +24 -0
- package/dist/src/hooks/yankHeaderMarkers.js +23 -0
- package/dist/src/hooks/yankHeaderMarkers.test.js +49 -0
- package/dist/src/index.js +30 -0
- package/dist/src/services.js +12 -0
- package/dist/src/state/atoms.js +27 -0
- package/dist/src/types.js +12 -0
- package/dist/src/utils/aws.js +39 -0
- package/dist/src/utils/debugLogger.js +34 -0
- package/dist/src/utils/secretDisplay.js +45 -0
- package/dist/src/utils/withFullscreen.js +38 -0
- package/dist/src/views/dynamodb/adapter.js +22 -0
- package/dist/src/views/iam/adapter.js +258 -0
- package/dist/src/views/iam/capabilities/detailCapability.js +93 -0
- package/dist/src/views/iam/capabilities/editCapability.js +59 -0
- package/dist/src/views/iam/capabilities/yankCapability.js +6 -0
- package/dist/src/views/iam/capabilities/yankOptions.js +15 -0
- package/dist/src/views/iam/schema.js +7 -0
- package/dist/src/views/iam/types.js +1 -0
- package/dist/src/views/iam/utils.js +21 -0
- package/dist/src/views/route53/adapter.js +22 -0
- package/dist/src/views/s3/adapter.js +154 -0
- package/dist/src/views/s3/capabilities/actionCapability.js +172 -0
- package/dist/src/views/s3/capabilities/detailCapability.js +115 -0
- package/dist/src/views/s3/capabilities/editCapability.js +35 -0
- package/dist/src/views/s3/capabilities/yankCapability.js +6 -0
- package/dist/src/views/s3/capabilities/yankOptions.js +55 -0
- package/dist/src/views/s3/client.js +12 -0
- package/dist/src/views/s3/fetcher.js +86 -0
- package/dist/src/views/s3/schema.js +6 -0
- package/dist/src/views/s3/utils.js +19 -0
- package/dist/src/views/secretsmanager/adapter.js +188 -0
- package/dist/src/views/secretsmanager/capabilities/actionCapability.js +193 -0
- package/dist/src/views/secretsmanager/capabilities/detailCapability.js +46 -0
- package/dist/src/views/secretsmanager/capabilities/editCapability.js +116 -0
- package/dist/src/views/secretsmanager/capabilities/yankCapability.js +7 -0
- package/dist/src/views/secretsmanager/capabilities/yankOptions.js +68 -0
- package/dist/src/views/secretsmanager/schema.js +28 -0
- package/dist/src/views/secretsmanager/types.js +1 -0
- package/package.json +68 -5
- package/index.js +0 -1
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { useState, useCallback } from "react";
|
|
2
|
+
// ---------------------------------------------------------------------------
|
|
3
|
+
// Pure helper: does (input, inkKey) match a single trigger?
|
|
4
|
+
// ---------------------------------------------------------------------------
|
|
5
|
+
function matchSpecial(inkKey, name) {
|
|
6
|
+
switch (name) {
|
|
7
|
+
case "return":
|
|
8
|
+
return inkKey.return;
|
|
9
|
+
case "escape":
|
|
10
|
+
return inkKey.escape;
|
|
11
|
+
case "tab":
|
|
12
|
+
return inkKey.tab;
|
|
13
|
+
case "upArrow":
|
|
14
|
+
return inkKey.upArrow;
|
|
15
|
+
case "downArrow":
|
|
16
|
+
return inkKey.downArrow;
|
|
17
|
+
case "leftArrow":
|
|
18
|
+
return inkKey.leftArrow;
|
|
19
|
+
case "rightArrow":
|
|
20
|
+
return inkKey.rightArrow;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
export function matchesTrigger(input, inkKey, trigger) {
|
|
24
|
+
switch (trigger.type) {
|
|
25
|
+
case "key":
|
|
26
|
+
return input === trigger.char;
|
|
27
|
+
case "special":
|
|
28
|
+
return matchSpecial(inkKey, trigger.name);
|
|
29
|
+
case "chord":
|
|
30
|
+
return false; // chords are resolved separately
|
|
31
|
+
case "any":
|
|
32
|
+
return trigger.of.some((t) => matchesTrigger(input, inkKey, t));
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
// ---------------------------------------------------------------------------
|
|
36
|
+
// Hook
|
|
37
|
+
// ---------------------------------------------------------------------------
|
|
38
|
+
/**
|
|
39
|
+
* Chord-aware key resolver.
|
|
40
|
+
*
|
|
41
|
+
* Usage:
|
|
42
|
+
* const { resolve } = useKeyChord(KEYBINDINGS);
|
|
43
|
+
* // inside useInput:
|
|
44
|
+
* const action = resolve(input, key, "navigate");
|
|
45
|
+
* if (action === KB.MOVE_DOWN) moveDown();
|
|
46
|
+
*
|
|
47
|
+
* Resolution order:
|
|
48
|
+
* 1. [...pending, input] completes a chord → fire action, clear pending
|
|
49
|
+
* 2. [...pending, input] is a valid chord prefix → accumulate pending, return null
|
|
50
|
+
* 3. Single-key / special-key match → fire action, clear pending
|
|
51
|
+
* 4. No match → clear pending, return null
|
|
52
|
+
*/
|
|
53
|
+
export function useKeyChord(bindings) {
|
|
54
|
+
const [pending, setPending] = useState([]);
|
|
55
|
+
const resolve = useCallback((input, inkKey, scope) => {
|
|
56
|
+
const scoped = bindings.filter((kb) => kb.scope === scope);
|
|
57
|
+
const next = pending.length > 0 ? [...pending, input] : [input];
|
|
58
|
+
// 1. Complete chord match
|
|
59
|
+
const chordHit = scoped.find((kb) => kb.trigger.type === "chord" &&
|
|
60
|
+
kb.trigger.keys.length === next.length &&
|
|
61
|
+
kb.trigger.keys.every((k, i) => k === next[i]));
|
|
62
|
+
if (chordHit) {
|
|
63
|
+
setPending([]);
|
|
64
|
+
return chordHit.action;
|
|
65
|
+
}
|
|
66
|
+
// 2. Chord prefix — accumulate and wait for next key
|
|
67
|
+
const isPrefix = scoped.some((kb) => kb.trigger.type === "chord" &&
|
|
68
|
+
kb.trigger.keys.length > next.length &&
|
|
69
|
+
kb.trigger.keys.slice(0, next.length).every((k, i) => k === next[i]));
|
|
70
|
+
if (isPrefix) {
|
|
71
|
+
setPending(next);
|
|
72
|
+
return null;
|
|
73
|
+
}
|
|
74
|
+
// 3. Single / special / any — only check when no chord is pending
|
|
75
|
+
// (if something is pending but didn't extend any chord, fall through and
|
|
76
|
+
// reset; the current key is then checked as a fresh single-key press)
|
|
77
|
+
setPending([]);
|
|
78
|
+
const hit = scoped.find((kb) => matchesTrigger(input, inkKey, kb.trigger));
|
|
79
|
+
return hit?.action ?? null;
|
|
80
|
+
}, [pending, bindings]);
|
|
81
|
+
const reset = useCallback(() => setPending([]), []);
|
|
82
|
+
return { pending, resolve, reset };
|
|
83
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { useEffect } from "react";
|
|
2
|
+
import { useInput } from "ink";
|
|
3
|
+
export function useMainInput(dispatch) {
|
|
4
|
+
useEffect(() => {
|
|
5
|
+
const handle = (data) => {
|
|
6
|
+
if (data.toString() === "\x03") {
|
|
7
|
+
dispatch({ scope: "system", type: "ctrl-c" });
|
|
8
|
+
}
|
|
9
|
+
};
|
|
10
|
+
process.stdin.on("data", handle);
|
|
11
|
+
return () => {
|
|
12
|
+
process.stdin.off("data", handle);
|
|
13
|
+
};
|
|
14
|
+
}, [dispatch]);
|
|
15
|
+
useInput((input, key) => {
|
|
16
|
+
dispatch({ scope: "raw", type: "key", input, key });
|
|
17
|
+
}, { isActive: true });
|
|
18
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { useState, useCallback } from "react";
|
|
2
|
+
export function useNavigation(rowCount, maxVisible) {
|
|
3
|
+
const [state, setState] = useState({ selectedIndex: 0, scrollOffset: 0 });
|
|
4
|
+
const moveUp = useCallback(() => {
|
|
5
|
+
setState((prev) => {
|
|
6
|
+
const next = Math.max(0, prev.selectedIndex - 1);
|
|
7
|
+
const off = next < prev.scrollOffset ? next : prev.scrollOffset;
|
|
8
|
+
return { selectedIndex: next, scrollOffset: off };
|
|
9
|
+
});
|
|
10
|
+
}, []);
|
|
11
|
+
const moveDown = useCallback(() => {
|
|
12
|
+
setState((prev) => {
|
|
13
|
+
const next = Math.min(Math.max(0, rowCount - 1), prev.selectedIndex + 1);
|
|
14
|
+
const off = next >= prev.scrollOffset + maxVisible ? next - maxVisible + 1 : prev.scrollOffset;
|
|
15
|
+
return { selectedIndex: next, scrollOffset: off };
|
|
16
|
+
});
|
|
17
|
+
}, [rowCount, maxVisible]);
|
|
18
|
+
const reset = useCallback(() => {
|
|
19
|
+
setState({ selectedIndex: 0, scrollOffset: 0 });
|
|
20
|
+
}, []);
|
|
21
|
+
const setIndex = useCallback((index) => {
|
|
22
|
+
setState(() => {
|
|
23
|
+
const next = Math.max(0, Math.min(Math.max(0, rowCount - 1), index));
|
|
24
|
+
const off = Math.max(0, next - Math.max(0, maxVisible - 1));
|
|
25
|
+
return { selectedIndex: next, scrollOffset: off };
|
|
26
|
+
});
|
|
27
|
+
}, [rowCount, maxVisible]);
|
|
28
|
+
const toTop = useCallback(() => {
|
|
29
|
+
setState({ selectedIndex: 0, scrollOffset: 0 });
|
|
30
|
+
}, []);
|
|
31
|
+
const toBottom = useCallback(() => {
|
|
32
|
+
const lastIndex = Math.max(0, rowCount - 1);
|
|
33
|
+
const bottomOffset = Math.max(0, rowCount - maxVisible);
|
|
34
|
+
setState({ selectedIndex: lastIndex, scrollOffset: bottomOffset });
|
|
35
|
+
}, [rowCount, maxVisible]);
|
|
36
|
+
const clampedIndex = Math.min(state.selectedIndex, Math.max(0, rowCount - 1));
|
|
37
|
+
return {
|
|
38
|
+
selectedIndex: clampedIndex,
|
|
39
|
+
scrollOffset: state.scrollOffset,
|
|
40
|
+
moveUp,
|
|
41
|
+
moveDown,
|
|
42
|
+
reset,
|
|
43
|
+
setIndex,
|
|
44
|
+
toTop,
|
|
45
|
+
toBottom,
|
|
46
|
+
};
|
|
47
|
+
}
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import { useMemo } from "react";
|
|
2
|
+
import { textCell } from "../types.js";
|
|
3
|
+
import { usePickerState } from "./usePickerState.js";
|
|
4
|
+
import { usePickerTable } from "./usePickerTable.js";
|
|
5
|
+
import { SERVICE_REGISTRY } from "../services.js";
|
|
6
|
+
export function usePickerManager({ tableHeight, availableRegions, availableProfiles, }) {
|
|
7
|
+
const region = usePickerState();
|
|
8
|
+
const profile = usePickerState();
|
|
9
|
+
const resource = usePickerState();
|
|
10
|
+
const regionRows = useMemo(() => availableRegions.map((r) => ({
|
|
11
|
+
id: r.name,
|
|
12
|
+
cells: { region: textCell(r.name), description: textCell(r.description) },
|
|
13
|
+
meta: {},
|
|
14
|
+
})), [availableRegions]);
|
|
15
|
+
const profileRows = useMemo(() => availableProfiles.map((p) => ({
|
|
16
|
+
id: p.name,
|
|
17
|
+
cells: { profile: textCell(p.name), description: textCell(p.description) },
|
|
18
|
+
meta: {},
|
|
19
|
+
})), [availableProfiles]);
|
|
20
|
+
const resourceRows = useMemo(() => Object.keys(SERVICE_REGISTRY).map((serviceId) => ({
|
|
21
|
+
id: serviceId,
|
|
22
|
+
cells: {
|
|
23
|
+
resource: textCell(serviceId),
|
|
24
|
+
description: textCell(`${serviceId.toUpperCase()} service`),
|
|
25
|
+
},
|
|
26
|
+
meta: {},
|
|
27
|
+
})), []);
|
|
28
|
+
const regionTable = usePickerTable({
|
|
29
|
+
rows: regionRows,
|
|
30
|
+
filterText: region.filter,
|
|
31
|
+
maxHeight: tableHeight,
|
|
32
|
+
});
|
|
33
|
+
const profileTable = usePickerTable({
|
|
34
|
+
rows: profileRows,
|
|
35
|
+
filterText: profile.filter,
|
|
36
|
+
maxHeight: tableHeight,
|
|
37
|
+
});
|
|
38
|
+
const resourceTable = usePickerTable({
|
|
39
|
+
rows: resourceRows,
|
|
40
|
+
filterText: resource.filter,
|
|
41
|
+
maxHeight: tableHeight,
|
|
42
|
+
});
|
|
43
|
+
const regionColumns = [
|
|
44
|
+
{ key: "region", label: "Region" },
|
|
45
|
+
{ key: "description", label: "Description" },
|
|
46
|
+
];
|
|
47
|
+
const profileColumns = [
|
|
48
|
+
{ key: "profile", label: "Profile" },
|
|
49
|
+
{ key: "description", label: "Description" },
|
|
50
|
+
];
|
|
51
|
+
const resourceColumns = [
|
|
52
|
+
{ key: "resource", label: "Resource" },
|
|
53
|
+
{ key: "description", label: "Description" },
|
|
54
|
+
];
|
|
55
|
+
const regionEntry = {
|
|
56
|
+
id: "region",
|
|
57
|
+
columns: regionColumns,
|
|
58
|
+
contextLabel: "Select AWS Region",
|
|
59
|
+
...region,
|
|
60
|
+
...regionTable,
|
|
61
|
+
};
|
|
62
|
+
const profileEntry = {
|
|
63
|
+
id: "profile",
|
|
64
|
+
columns: profileColumns,
|
|
65
|
+
contextLabel: "Select AWS Profile",
|
|
66
|
+
...profile,
|
|
67
|
+
...profileTable,
|
|
68
|
+
};
|
|
69
|
+
const resourceEntry = {
|
|
70
|
+
id: "resource",
|
|
71
|
+
columns: resourceColumns,
|
|
72
|
+
contextLabel: "Select AWS Resource",
|
|
73
|
+
...resource,
|
|
74
|
+
...resourceTable,
|
|
75
|
+
};
|
|
76
|
+
const activePicker = regionEntry.open
|
|
77
|
+
? regionEntry
|
|
78
|
+
: profileEntry.open
|
|
79
|
+
? profileEntry
|
|
80
|
+
: resourceEntry.open
|
|
81
|
+
? resourceEntry
|
|
82
|
+
: null;
|
|
83
|
+
const getEntry = (id) => {
|
|
84
|
+
switch (id) {
|
|
85
|
+
case "region":
|
|
86
|
+
return regionEntry;
|
|
87
|
+
case "profile":
|
|
88
|
+
return profileEntry;
|
|
89
|
+
case "resource":
|
|
90
|
+
return resourceEntry;
|
|
91
|
+
}
|
|
92
|
+
};
|
|
93
|
+
const openPicker = (id) => {
|
|
94
|
+
const entry = getEntry(id);
|
|
95
|
+
entry.openPicker();
|
|
96
|
+
entry.reset();
|
|
97
|
+
};
|
|
98
|
+
const closeActivePicker = () => {
|
|
99
|
+
activePicker?.closePicker();
|
|
100
|
+
};
|
|
101
|
+
const resetPicker = (id) => {
|
|
102
|
+
getEntry(id).reset();
|
|
103
|
+
};
|
|
104
|
+
const confirmActivePickerSelection = (handlers) => {
|
|
105
|
+
if (!activePicker?.selectedRow)
|
|
106
|
+
return;
|
|
107
|
+
switch (activePicker.id) {
|
|
108
|
+
case "resource":
|
|
109
|
+
handlers.onSelectResource(activePicker.selectedRow.id);
|
|
110
|
+
break;
|
|
111
|
+
case "region":
|
|
112
|
+
handlers.onSelectRegion(activePicker.selectedRow.id);
|
|
113
|
+
break;
|
|
114
|
+
case "profile":
|
|
115
|
+
handlers.onSelectProfile(activePicker.selectedRow.id);
|
|
116
|
+
break;
|
|
117
|
+
}
|
|
118
|
+
activePicker.closePicker();
|
|
119
|
+
};
|
|
120
|
+
return {
|
|
121
|
+
region: regionEntry,
|
|
122
|
+
profile: profileEntry,
|
|
123
|
+
resource: resourceEntry,
|
|
124
|
+
activePicker,
|
|
125
|
+
openPicker,
|
|
126
|
+
closeActivePicker,
|
|
127
|
+
resetPicker,
|
|
128
|
+
confirmActivePickerSelection,
|
|
129
|
+
};
|
|
130
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { useState, useCallback } from "react";
|
|
2
|
+
export function usePickerState() {
|
|
3
|
+
const [open, setOpen] = useState(false);
|
|
4
|
+
const [filter, setFilter] = useState("");
|
|
5
|
+
const [searchEntry, setSearchEntry] = useState(null);
|
|
6
|
+
const [pickerMode, setPickerMode] = useState("navigate");
|
|
7
|
+
const openPicker = useCallback(() => {
|
|
8
|
+
setOpen(true);
|
|
9
|
+
setFilter("");
|
|
10
|
+
setSearchEntry(null);
|
|
11
|
+
setPickerMode("navigate");
|
|
12
|
+
}, []);
|
|
13
|
+
const closePicker = useCallback(() => {
|
|
14
|
+
setOpen(false);
|
|
15
|
+
setFilter("");
|
|
16
|
+
setSearchEntry(null);
|
|
17
|
+
setPickerMode("navigate");
|
|
18
|
+
}, []);
|
|
19
|
+
const startSearch = useCallback(() => {
|
|
20
|
+
setSearchEntry((prev) => prev ?? filter);
|
|
21
|
+
setPickerMode("search");
|
|
22
|
+
}, [filter]);
|
|
23
|
+
const cancelSearch = useCallback(() => {
|
|
24
|
+
setFilter((current) => {
|
|
25
|
+
const restored = searchEntry !== null && current !== "" ? searchEntry : current;
|
|
26
|
+
setSearchEntry(null);
|
|
27
|
+
setPickerMode("navigate");
|
|
28
|
+
return restored;
|
|
29
|
+
});
|
|
30
|
+
}, [searchEntry]);
|
|
31
|
+
const confirmSearch = useCallback(() => {
|
|
32
|
+
setSearchEntry(null);
|
|
33
|
+
setPickerMode("navigate");
|
|
34
|
+
}, []);
|
|
35
|
+
return {
|
|
36
|
+
open,
|
|
37
|
+
filter,
|
|
38
|
+
searchEntry,
|
|
39
|
+
pickerMode,
|
|
40
|
+
setFilter,
|
|
41
|
+
openPicker,
|
|
42
|
+
closePicker,
|
|
43
|
+
startSearch,
|
|
44
|
+
cancelSearch,
|
|
45
|
+
confirmSearch,
|
|
46
|
+
};
|
|
47
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { useMemo } from "react";
|
|
2
|
+
import { useNavigation } from "./useNavigation.js";
|
|
3
|
+
export function usePickerTable({ rows, filterText, maxHeight }) {
|
|
4
|
+
const filteredRows = useMemo(() => {
|
|
5
|
+
if (!filterText)
|
|
6
|
+
return rows;
|
|
7
|
+
const lower = filterText.toLowerCase();
|
|
8
|
+
return rows.filter((row) => Object.values(row.cells).some((cell) => {
|
|
9
|
+
const value = typeof cell === "string" ? cell : cell.displayName;
|
|
10
|
+
return value.toLowerCase().includes(lower);
|
|
11
|
+
}));
|
|
12
|
+
}, [filterText, rows]);
|
|
13
|
+
const nav = useNavigation(filteredRows.length, maxHeight);
|
|
14
|
+
const selectedRow = filteredRows[nav.selectedIndex] ?? null;
|
|
15
|
+
return {
|
|
16
|
+
filteredRows,
|
|
17
|
+
selectedRow,
|
|
18
|
+
...nav,
|
|
19
|
+
};
|
|
20
|
+
}
|
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
import { useCallback, useEffect, useLayoutEffect, useReducer } from "react";
|
|
2
|
+
import { useAtomValue } from "jotai";
|
|
3
|
+
import open from "open";
|
|
4
|
+
import { extname } from "path";
|
|
5
|
+
import { execSync } from "child_process";
|
|
6
|
+
import { stat } from "fs/promises";
|
|
7
|
+
import { debugLog } from "../utils/debugLogger.js";
|
|
8
|
+
import { adapterSessionAtom } from "../state/atoms.js";
|
|
9
|
+
const TEXT_EXTENSIONS = new Set([
|
|
10
|
+
".txt",
|
|
11
|
+
".json",
|
|
12
|
+
".yaml",
|
|
13
|
+
".yml",
|
|
14
|
+
".xml",
|
|
15
|
+
".html",
|
|
16
|
+
".htm",
|
|
17
|
+
".css",
|
|
18
|
+
".js",
|
|
19
|
+
".ts",
|
|
20
|
+
".tsx",
|
|
21
|
+
".jsx",
|
|
22
|
+
".py",
|
|
23
|
+
".rb",
|
|
24
|
+
".go",
|
|
25
|
+
".java",
|
|
26
|
+
".c",
|
|
27
|
+
".cpp",
|
|
28
|
+
".h",
|
|
29
|
+
".sh",
|
|
30
|
+
".bash",
|
|
31
|
+
".zsh",
|
|
32
|
+
".fish",
|
|
33
|
+
".md",
|
|
34
|
+
".markdown",
|
|
35
|
+
".rst",
|
|
36
|
+
".sql",
|
|
37
|
+
".sql",
|
|
38
|
+
".toml",
|
|
39
|
+
".ini",
|
|
40
|
+
".env",
|
|
41
|
+
".log",
|
|
42
|
+
".csv",
|
|
43
|
+
".tsv",
|
|
44
|
+
".properties",
|
|
45
|
+
".gradle",
|
|
46
|
+
".maven",
|
|
47
|
+
".dockerfile",
|
|
48
|
+
".gitignore",
|
|
49
|
+
".npmrc",
|
|
50
|
+
".editorconfig",
|
|
51
|
+
]);
|
|
52
|
+
function isTextFile(filePath) {
|
|
53
|
+
const ext = extname(filePath).toLowerCase();
|
|
54
|
+
return TEXT_EXTENSIONS.has(ext) || !ext;
|
|
55
|
+
}
|
|
56
|
+
async function openFile(filePath) {
|
|
57
|
+
try {
|
|
58
|
+
if (isTextFile(filePath)) {
|
|
59
|
+
const editor = process.env["EDITOR"] || "vim";
|
|
60
|
+
// execSync blocks and lets editor take full control of terminal
|
|
61
|
+
// Ink will automatically suspend and resume when this completes
|
|
62
|
+
execSync(`${editor} "${filePath}"`, { stdio: "inherit" });
|
|
63
|
+
}
|
|
64
|
+
else {
|
|
65
|
+
await open(filePath);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
catch (err) {
|
|
69
|
+
console.error("Failed to open file:", err.message);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
function dataReducer(state, action) {
|
|
73
|
+
switch (action.type) {
|
|
74
|
+
case "ADAPTER_CHANGED":
|
|
75
|
+
return {
|
|
76
|
+
adapterId: action.adapterId,
|
|
77
|
+
rows: [],
|
|
78
|
+
columns: [],
|
|
79
|
+
loadingCount: 0,
|
|
80
|
+
error: null,
|
|
81
|
+
};
|
|
82
|
+
case "BEGIN_LOADING":
|
|
83
|
+
return { ...state, loadingCount: state.loadingCount + 1 };
|
|
84
|
+
case "END_LOADING":
|
|
85
|
+
return { ...state, loadingCount: Math.max(0, state.loadingCount - 1) };
|
|
86
|
+
case "SET_DATA":
|
|
87
|
+
return { ...state, rows: action.rows, columns: action.columns };
|
|
88
|
+
case "SET_ERROR":
|
|
89
|
+
return { ...state, error: action.error };
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
export function useServiceView(adapter, navKey) {
|
|
93
|
+
const adapterId = adapter.id;
|
|
94
|
+
const adapterSession = useAtomValue(adapterSessionAtom);
|
|
95
|
+
const [state, dispatch] = useReducer(dataReducer, {
|
|
96
|
+
adapterId,
|
|
97
|
+
rows: [],
|
|
98
|
+
columns: [],
|
|
99
|
+
loadingCount: 0,
|
|
100
|
+
error: null,
|
|
101
|
+
});
|
|
102
|
+
// Clear data atomically when adapter session changes (before paint)
|
|
103
|
+
useLayoutEffect(() => {
|
|
104
|
+
if (state.adapterId !== adapterId) {
|
|
105
|
+
debugLog(adapterId, "useLayoutEffect: adapter changed, clearing data");
|
|
106
|
+
dispatch({ type: "ADAPTER_CHANGED", adapterId });
|
|
107
|
+
// Reset adapter-specific state
|
|
108
|
+
adapter.reset?.();
|
|
109
|
+
}
|
|
110
|
+
}, [adapterSession, adapterId, state.adapterId]);
|
|
111
|
+
const beginLoading = useCallback(() => {
|
|
112
|
+
debugLog(adapterId, "beginLoading");
|
|
113
|
+
dispatch({ type: "BEGIN_LOADING" });
|
|
114
|
+
}, [adapterId]);
|
|
115
|
+
const endLoading = useCallback(() => {
|
|
116
|
+
debugLog(adapterId, "endLoading");
|
|
117
|
+
dispatch({ type: "END_LOADING" });
|
|
118
|
+
}, [adapterId]);
|
|
119
|
+
const runWithLoading = useCallback(async (fn, clearError = false) => {
|
|
120
|
+
beginLoading();
|
|
121
|
+
if (clearError)
|
|
122
|
+
dispatch({ type: "SET_ERROR", error: null });
|
|
123
|
+
try {
|
|
124
|
+
return await fn();
|
|
125
|
+
}
|
|
126
|
+
finally {
|
|
127
|
+
endLoading();
|
|
128
|
+
}
|
|
129
|
+
}, [beginLoading, endLoading]);
|
|
130
|
+
const refresh = useCallback(async () => {
|
|
131
|
+
debugLog(adapterId, "refresh() called");
|
|
132
|
+
return runWithLoading(async () => {
|
|
133
|
+
debugLog(adapterId, "fetching rows...");
|
|
134
|
+
const columns = adapter.getColumns();
|
|
135
|
+
dispatch({ type: "SET_ERROR", error: null });
|
|
136
|
+
try {
|
|
137
|
+
const r = await adapter.getRows();
|
|
138
|
+
debugLog(adapterId, `got ${r.length} rows from adapter`);
|
|
139
|
+
dispatch({ type: "SET_DATA", rows: r, columns });
|
|
140
|
+
}
|
|
141
|
+
catch (e) {
|
|
142
|
+
debugLog(adapterId, "fetch error", e.message);
|
|
143
|
+
dispatch({ type: "SET_ERROR", error: e.message });
|
|
144
|
+
}
|
|
145
|
+
}, true);
|
|
146
|
+
}, [adapter, runWithLoading, adapterId]);
|
|
147
|
+
useEffect(() => {
|
|
148
|
+
debugLog(adapterId, "useEffect: adapter changed, fetching data");
|
|
149
|
+
void (async () => {
|
|
150
|
+
dispatch({ type: "BEGIN_LOADING" });
|
|
151
|
+
dispatch({ type: "SET_ERROR", error: null });
|
|
152
|
+
debugLog(adapterId, "fetching rows...");
|
|
153
|
+
const columns = adapter.getColumns();
|
|
154
|
+
try {
|
|
155
|
+
const r = await adapter.getRows();
|
|
156
|
+
debugLog(adapterId, `got ${r.length} rows from adapter`);
|
|
157
|
+
dispatch({ type: "SET_DATA", rows: r, columns });
|
|
158
|
+
}
|
|
159
|
+
catch (e) {
|
|
160
|
+
debugLog(adapterId, "fetch error", e.message);
|
|
161
|
+
dispatch({ type: "SET_ERROR", error: e.message });
|
|
162
|
+
}
|
|
163
|
+
finally {
|
|
164
|
+
dispatch({ type: "END_LOADING" });
|
|
165
|
+
}
|
|
166
|
+
})();
|
|
167
|
+
}, [adapter, navKey, adapterId]);
|
|
168
|
+
const processResult = useCallback(async (result) => {
|
|
169
|
+
if (result.action === "navigate")
|
|
170
|
+
await refresh();
|
|
171
|
+
if (result.action === "edit") {
|
|
172
|
+
// Get file modification time before opening
|
|
173
|
+
const before = await stat(result.filePath).catch(() => null);
|
|
174
|
+
const beforeMtime = before?.mtimeMs;
|
|
175
|
+
// Open file in editor
|
|
176
|
+
await openFile(result.filePath);
|
|
177
|
+
// Check if file was modified
|
|
178
|
+
const after = await stat(result.filePath).catch(() => null);
|
|
179
|
+
const afterMtime = after?.mtimeMs;
|
|
180
|
+
if (beforeMtime &&
|
|
181
|
+
afterMtime &&
|
|
182
|
+
beforeMtime !== afterMtime &&
|
|
183
|
+
adapter.capabilities?.edit &&
|
|
184
|
+
result.metadata) {
|
|
185
|
+
return { ...result, needsUpload: true };
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
return result;
|
|
189
|
+
}, [adapter, refresh]);
|
|
190
|
+
const select = useCallback(async (row) => {
|
|
191
|
+
debugLog(adapterId, "select() called for row", row.id);
|
|
192
|
+
return runWithLoading(async () => {
|
|
193
|
+
const result = await adapter.onSelect(row);
|
|
194
|
+
return processResult(result);
|
|
195
|
+
});
|
|
196
|
+
}, [adapter, processResult, runWithLoading, adapterId]);
|
|
197
|
+
const edit = useCallback(async (row) => {
|
|
198
|
+
return runWithLoading(async () => {
|
|
199
|
+
const result = adapter.capabilities?.edit
|
|
200
|
+
? await adapter.capabilities.edit.onEdit(row)
|
|
201
|
+
: await adapter.onSelect(row);
|
|
202
|
+
return processResult(result);
|
|
203
|
+
});
|
|
204
|
+
}, [adapter, processResult, runWithLoading]);
|
|
205
|
+
const goBack = useCallback(async () => {
|
|
206
|
+
if (!adapter.canGoBack())
|
|
207
|
+
return;
|
|
208
|
+
await runWithLoading(async () => {
|
|
209
|
+
adapter.goBack();
|
|
210
|
+
await refresh();
|
|
211
|
+
});
|
|
212
|
+
}, [adapter, refresh, runWithLoading]);
|
|
213
|
+
// Derive effective values: detect if reducer state is stale (adapter changed but ADAPTER_CHANGED not yet processed)
|
|
214
|
+
const isTransitioning = state.adapterId !== adapterId;
|
|
215
|
+
return {
|
|
216
|
+
rows: isTransitioning ? [] : state.rows,
|
|
217
|
+
columns: isTransitioning ? [] : state.columns,
|
|
218
|
+
isLoading: isTransitioning || state.loadingCount > 0,
|
|
219
|
+
error: isTransitioning ? null : state.error,
|
|
220
|
+
select,
|
|
221
|
+
edit,
|
|
222
|
+
goBack,
|
|
223
|
+
refresh,
|
|
224
|
+
path: adapter.getPath(),
|
|
225
|
+
};
|
|
226
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { useMemo } from "react";
|
|
2
|
+
import { COMMAND_MODE_HINT } from "../constants/commands.js";
|
|
3
|
+
import { buildScopeHint } from "../constants/keybindings.js";
|
|
4
|
+
export function useUiHints({ mode, helpOpen, pickers, pendingAction, uploadPending, describeState, yankMode, adapterBindings, yankHint, context, }) {
|
|
5
|
+
const uiScope = useMemo(() => {
|
|
6
|
+
if (helpOpen)
|
|
7
|
+
return "help";
|
|
8
|
+
if (pickers.activePicker)
|
|
9
|
+
return "picker";
|
|
10
|
+
if (pendingAction)
|
|
11
|
+
return "adapter-action";
|
|
12
|
+
if (uploadPending)
|
|
13
|
+
return "upload";
|
|
14
|
+
if (describeState)
|
|
15
|
+
return "details";
|
|
16
|
+
if (yankMode)
|
|
17
|
+
return "yank";
|
|
18
|
+
if (mode === "search")
|
|
19
|
+
return "search";
|
|
20
|
+
if (mode === "command")
|
|
21
|
+
return "command";
|
|
22
|
+
return "navigate";
|
|
23
|
+
}, [describeState, helpOpen, mode, pendingAction, pickers.activePicker, uploadPending, yankMode]);
|
|
24
|
+
const bottomHint = useMemo(() => {
|
|
25
|
+
switch (uiScope) {
|
|
26
|
+
case "help":
|
|
27
|
+
return buildScopeHint("help", adapterBindings, 8, context);
|
|
28
|
+
case "picker":
|
|
29
|
+
return pickers.activePicker?.pickerMode === "search"
|
|
30
|
+
? buildScopeHint("search", adapterBindings, 8, context)
|
|
31
|
+
: buildScopeHint("picker", adapterBindings, 8, context);
|
|
32
|
+
case "adapter-action":
|
|
33
|
+
if (pendingAction?.effect.type === "prompt") {
|
|
34
|
+
return " Enter value • Esc cancel";
|
|
35
|
+
}
|
|
36
|
+
if (pendingAction?.effect.type === "confirm") {
|
|
37
|
+
return " y confirm • n/Esc cancel";
|
|
38
|
+
}
|
|
39
|
+
return "";
|
|
40
|
+
case "upload":
|
|
41
|
+
return buildScopeHint("upload", adapterBindings, 8, context);
|
|
42
|
+
case "details":
|
|
43
|
+
return buildScopeHint("details", adapterBindings, 8, context);
|
|
44
|
+
case "yank":
|
|
45
|
+
return ` ${yankHint}`;
|
|
46
|
+
case "search":
|
|
47
|
+
return buildScopeHint("search", adapterBindings, 8, context);
|
|
48
|
+
case "command":
|
|
49
|
+
return COMMAND_MODE_HINT;
|
|
50
|
+
case "navigate":
|
|
51
|
+
return buildScopeHint("navigate", adapterBindings, 8, context);
|
|
52
|
+
default:
|
|
53
|
+
return "";
|
|
54
|
+
}
|
|
55
|
+
}, [adapterBindings, context, pendingAction, pickers.activePicker, uiScope, yankHint]);
|
|
56
|
+
return {
|
|
57
|
+
uiScope,
|
|
58
|
+
bottomHint,
|
|
59
|
+
};
|
|
60
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { useState, useCallback, useEffect } from "react";
|
|
2
|
+
export function useYankMode() {
|
|
3
|
+
const [yankMode, setYankMode] = useState(false);
|
|
4
|
+
const [yankFeedback, setYankFeedback] = useState(null);
|
|
5
|
+
useEffect(() => {
|
|
6
|
+
return () => {
|
|
7
|
+
if (yankFeedback?.timer)
|
|
8
|
+
clearTimeout(yankFeedback.timer);
|
|
9
|
+
};
|
|
10
|
+
}, [yankFeedback]);
|
|
11
|
+
const pushYankFeedback = useCallback((message, durationMs = 1500) => {
|
|
12
|
+
const timer = setTimeout(() => setYankFeedback(null), durationMs);
|
|
13
|
+
setYankFeedback({ message, timer });
|
|
14
|
+
}, []);
|
|
15
|
+
const clearYankFeedback = useCallback(() => setYankFeedback(null), []);
|
|
16
|
+
return {
|
|
17
|
+
yankMode,
|
|
18
|
+
setYankMode,
|
|
19
|
+
yankFeedback,
|
|
20
|
+
setYankFeedback,
|
|
21
|
+
pushYankFeedback,
|
|
22
|
+
clearYankFeedback,
|
|
23
|
+
};
|
|
24
|
+
}
|