@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.
Files changed (95) hide show
  1. package/README.md +167 -2
  2. package/dist/scripts/seed.js +310 -0
  3. package/dist/src/App.js +476 -0
  4. package/dist/src/adapters/ServiceAdapter.js +1 -0
  5. package/dist/src/adapters/capabilities/ActionCapability.js +1 -0
  6. package/dist/src/adapters/capabilities/DetailCapability.js +1 -0
  7. package/dist/src/adapters/capabilities/EditCapability.js +1 -0
  8. package/dist/src/adapters/capabilities/YankCapability.js +42 -0
  9. package/dist/src/adapters/capabilities/YankCapability.test.js +29 -0
  10. package/dist/src/components/AdvancedTextInput.js +200 -0
  11. package/dist/src/components/AdvancedTextInput.test.js +190 -0
  12. package/dist/src/components/AutocompleteInput.js +29 -0
  13. package/dist/src/components/DetailPanel.js +12 -0
  14. package/dist/src/components/DiffViewer.js +17 -0
  15. package/dist/src/components/ErrorStatePanel.js +5 -0
  16. package/dist/src/components/HUD.js +31 -0
  17. package/dist/src/components/HelpPanel.js +33 -0
  18. package/dist/src/components/ModeBar.js +43 -0
  19. package/dist/src/components/Table/index.js +109 -0
  20. package/dist/src/components/Table/widths.js +19 -0
  21. package/dist/src/components/TableSkeleton.js +25 -0
  22. package/dist/src/components/YankHelpPanel.js +43 -0
  23. package/dist/src/constants/commands.js +15 -0
  24. package/dist/src/constants/keybindings.js +530 -0
  25. package/dist/src/constants/keys.js +37 -0
  26. package/dist/src/features/AppMainView.integration.test.js +133 -0
  27. package/dist/src/features/AppMainView.js +95 -0
  28. package/dist/src/hooks/inputEvents.js +1 -0
  29. package/dist/src/hooks/mainInputScopes.js +68 -0
  30. package/dist/src/hooks/mainInputScopes.test.js +24 -0
  31. package/dist/src/hooks/useActionController.js +78 -0
  32. package/dist/src/hooks/useAppController.js +102 -0
  33. package/dist/src/hooks/useAppController.test.js +54 -0
  34. package/dist/src/hooks/useAppData.js +48 -0
  35. package/dist/src/hooks/useAwsContext.js +77 -0
  36. package/dist/src/hooks/useAwsProfiles.js +53 -0
  37. package/dist/src/hooks/useAwsRegions.js +105 -0
  38. package/dist/src/hooks/useCommandRouter.js +56 -0
  39. package/dist/src/hooks/useCommandRouter.test.js +27 -0
  40. package/dist/src/hooks/useDetailController.js +57 -0
  41. package/dist/src/hooks/useDetailController.test.js +32 -0
  42. package/dist/src/hooks/useHelpPanel.js +65 -0
  43. package/dist/src/hooks/useHierarchyState.js +39 -0
  44. package/dist/src/hooks/useInputEventProcessor.js +450 -0
  45. package/dist/src/hooks/useInputEventProcessor.test.js +174 -0
  46. package/dist/src/hooks/useKeyChord.js +83 -0
  47. package/dist/src/hooks/useMainInput.js +18 -0
  48. package/dist/src/hooks/useNavigation.js +47 -0
  49. package/dist/src/hooks/usePendingAction.js +8 -0
  50. package/dist/src/hooks/usePickerManager.js +130 -0
  51. package/dist/src/hooks/usePickerState.js +47 -0
  52. package/dist/src/hooks/usePickerTable.js +20 -0
  53. package/dist/src/hooks/useServiceView.js +226 -0
  54. package/dist/src/hooks/useUiHints.js +60 -0
  55. package/dist/src/hooks/useYankMode.js +24 -0
  56. package/dist/src/hooks/yankHeaderMarkers.js +23 -0
  57. package/dist/src/hooks/yankHeaderMarkers.test.js +49 -0
  58. package/dist/src/index.js +30 -0
  59. package/dist/src/services.js +12 -0
  60. package/dist/src/state/atoms.js +27 -0
  61. package/dist/src/types.js +12 -0
  62. package/dist/src/utils/aws.js +39 -0
  63. package/dist/src/utils/debugLogger.js +34 -0
  64. package/dist/src/utils/secretDisplay.js +45 -0
  65. package/dist/src/utils/withFullscreen.js +38 -0
  66. package/dist/src/views/dynamodb/adapter.js +22 -0
  67. package/dist/src/views/iam/adapter.js +258 -0
  68. package/dist/src/views/iam/capabilities/detailCapability.js +93 -0
  69. package/dist/src/views/iam/capabilities/editCapability.js +59 -0
  70. package/dist/src/views/iam/capabilities/yankCapability.js +6 -0
  71. package/dist/src/views/iam/capabilities/yankOptions.js +15 -0
  72. package/dist/src/views/iam/schema.js +7 -0
  73. package/dist/src/views/iam/types.js +1 -0
  74. package/dist/src/views/iam/utils.js +21 -0
  75. package/dist/src/views/route53/adapter.js +22 -0
  76. package/dist/src/views/s3/adapter.js +154 -0
  77. package/dist/src/views/s3/capabilities/actionCapability.js +172 -0
  78. package/dist/src/views/s3/capabilities/detailCapability.js +115 -0
  79. package/dist/src/views/s3/capabilities/editCapability.js +35 -0
  80. package/dist/src/views/s3/capabilities/yankCapability.js +6 -0
  81. package/dist/src/views/s3/capabilities/yankOptions.js +55 -0
  82. package/dist/src/views/s3/client.js +12 -0
  83. package/dist/src/views/s3/fetcher.js +86 -0
  84. package/dist/src/views/s3/schema.js +6 -0
  85. package/dist/src/views/s3/utils.js +19 -0
  86. package/dist/src/views/secretsmanager/adapter.js +188 -0
  87. package/dist/src/views/secretsmanager/capabilities/actionCapability.js +193 -0
  88. package/dist/src/views/secretsmanager/capabilities/detailCapability.js +46 -0
  89. package/dist/src/views/secretsmanager/capabilities/editCapability.js +116 -0
  90. package/dist/src/views/secretsmanager/capabilities/yankCapability.js +7 -0
  91. package/dist/src/views/secretsmanager/capabilities/yankOptions.js +68 -0
  92. package/dist/src/views/secretsmanager/schema.js +28 -0
  93. package/dist/src/views/secretsmanager/types.js +1 -0
  94. package/package.json +68 -5
  95. 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,8 @@
1
+ import { useState } from "react";
2
+ export function usePendingAction() {
3
+ const [pendingAction, setPendingAction] = useState(null);
4
+ return {
5
+ pendingAction,
6
+ setPendingAction,
7
+ };
8
+ }
@@ -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
+ }