@nexus-ai-fs/tui 0.9.18

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 (193) hide show
  1. package/README.md +30 -0
  2. package/package.json +48 -0
  3. package/src/app.tsx +349 -0
  4. package/src/index.tsx +137 -0
  5. package/src/opentui-env.d.ts +61 -0
  6. package/src/panels/access/access-panel.tsx +597 -0
  7. package/src/panels/access/alert-list.tsx +77 -0
  8. package/src/panels/access/constraint-creator.tsx +128 -0
  9. package/src/panels/access/constraint-list.tsx +72 -0
  10. package/src/panels/access/credential-list.tsx +68 -0
  11. package/src/panels/access/delegation-chain-view.tsx +110 -0
  12. package/src/panels/access/delegation-completer.tsx +120 -0
  13. package/src/panels/access/delegation-creator.tsx +237 -0
  14. package/src/panels/access/delegation-list.tsx +74 -0
  15. package/src/panels/access/fraud-score-view.tsx +94 -0
  16. package/src/panels/access/manifest-creator.tsx +167 -0
  17. package/src/panels/access/manifest-list.tsx +105 -0
  18. package/src/panels/access/namespace-config-view.tsx +525 -0
  19. package/src/panels/access/permission-checker.tsx +231 -0
  20. package/src/panels/agents/agent-status-view.tsx +196 -0
  21. package/src/panels/agents/agents-panel.tsx +493 -0
  22. package/src/panels/agents/delegation-list.tsx +154 -0
  23. package/src/panels/agents/inbox-view.tsx +96 -0
  24. package/src/panels/agents/trajectories-tab.tsx +40 -0
  25. package/src/panels/api-console/api-console-panel.tsx +189 -0
  26. package/src/panels/api-console/codegen-viewer.tsx +36 -0
  27. package/src/panels/api-console/codegen.ts +112 -0
  28. package/src/panels/api-console/endpoint-list.tsx +57 -0
  29. package/src/panels/api-console/request-builder.tsx +69 -0
  30. package/src/panels/api-console/response-viewer.tsx +54 -0
  31. package/src/panels/connectors/available-tab.tsx +357 -0
  32. package/src/panels/connectors/connector-row.tsx +121 -0
  33. package/src/panels/connectors/connectors-panel.tsx +88 -0
  34. package/src/panels/connectors/error-parser.ts +116 -0
  35. package/src/panels/connectors/mounted-tab.tsx +179 -0
  36. package/src/panels/connectors/skills-tab.tsx +235 -0
  37. package/src/panels/connectors/template-generator.ts +211 -0
  38. package/src/panels/connectors/write-tab.tsx +514 -0
  39. package/src/panels/events/audit-tab.tsx +69 -0
  40. package/src/panels/events/audit-trail.tsx +75 -0
  41. package/src/panels/events/connector-detail.tsx +49 -0
  42. package/src/panels/events/connector-list.tsx +73 -0
  43. package/src/panels/events/connectors-tab.tsx +92 -0
  44. package/src/panels/events/event-replay.tsx +80 -0
  45. package/src/panels/events/events-panel.tsx +414 -0
  46. package/src/panels/events/events-tab.tsx +212 -0
  47. package/src/panels/events/lock-list.tsx +54 -0
  48. package/src/panels/events/locks-tab.tsx +103 -0
  49. package/src/panels/events/mcl-replay.tsx +77 -0
  50. package/src/panels/events/mcl-tab.tsx +83 -0
  51. package/src/panels/events/operations-tab-wrapper.tsx +62 -0
  52. package/src/panels/events/operations-tab.tsx +41 -0
  53. package/src/panels/events/replay-tab.tsx +76 -0
  54. package/src/panels/events/secrets-audit.tsx +64 -0
  55. package/src/panels/events/secrets-tab.tsx +75 -0
  56. package/src/panels/events/subscription-list.tsx +54 -0
  57. package/src/panels/events/subscriptions-tab.tsx +82 -0
  58. package/src/panels/files/file-aspects.tsx +93 -0
  59. package/src/panels/files/file-editor.tsx +160 -0
  60. package/src/panels/files/file-explorer-keybindings.ts +468 -0
  61. package/src/panels/files/file-explorer-panel.tsx +545 -0
  62. package/src/panels/files/file-lineage.tsx +163 -0
  63. package/src/panels/files/file-list-item.tsx +28 -0
  64. package/src/panels/files/file-metadata.tsx +62 -0
  65. package/src/panels/files/file-preview.tsx +108 -0
  66. package/src/panels/files/file-schema.tsx +89 -0
  67. package/src/panels/files/file-tree-node.tsx +44 -0
  68. package/src/panels/files/file-tree.tsx +169 -0
  69. package/src/panels/files/share-links-tab.tsx +33 -0
  70. package/src/panels/files/uploads-tab.tsx +45 -0
  71. package/src/panels/payments/approval-list.tsx +83 -0
  72. package/src/panels/payments/balance-card.tsx +43 -0
  73. package/src/panels/payments/budget-card.tsx +70 -0
  74. package/src/panels/payments/payments-panel.tsx +451 -0
  75. package/src/panels/payments/policy-list.tsx +64 -0
  76. package/src/panels/payments/reservation-list.tsx +78 -0
  77. package/src/panels/payments/transaction-list.tsx +103 -0
  78. package/src/panels/payments/transfer-form.tsx +109 -0
  79. package/src/panels/search/column-search.tsx +79 -0
  80. package/src/panels/search/knowledge-view.tsx +100 -0
  81. package/src/panels/search/memory-list.tsx +197 -0
  82. package/src/panels/search/playbook-list.tsx +77 -0
  83. package/src/panels/search/rlm-answer-view.tsx +105 -0
  84. package/src/panels/search/search-panel.tsx +405 -0
  85. package/src/panels/search/search-results.tsx +116 -0
  86. package/src/panels/stack/stack-panel.tsx +474 -0
  87. package/src/panels/versions/conflicts-tab.tsx +59 -0
  88. package/src/panels/versions/entry-detail.tsx +89 -0
  89. package/src/panels/versions/transaction-actions.tsx +34 -0
  90. package/src/panels/versions/transaction-list.tsx +90 -0
  91. package/src/panels/versions/versions-panel.tsx +276 -0
  92. package/src/panels/workflows/execution-list.tsx +102 -0
  93. package/src/panels/workflows/scheduler-view.tsx +135 -0
  94. package/src/panels/workflows/workflow-list.tsx +88 -0
  95. package/src/panels/workflows/workflows-panel.tsx +295 -0
  96. package/src/panels/zones/brick-detail.tsx +136 -0
  97. package/src/panels/zones/brick-list.tsx +56 -0
  98. package/src/panels/zones/cache-tab.tsx +118 -0
  99. package/src/panels/zones/drift-view.tsx +97 -0
  100. package/src/panels/zones/mcp-mounts-tab.tsx +38 -0
  101. package/src/panels/zones/memories-tab.tsx +37 -0
  102. package/src/panels/zones/reindex-status.tsx +84 -0
  103. package/src/panels/zones/workspaces-tab.tsx +37 -0
  104. package/src/panels/zones/zone-list.tsx +73 -0
  105. package/src/panels/zones/zones-panel.tsx +559 -0
  106. package/src/services/command-runner.ts +303 -0
  107. package/src/shared/accessibility-announcements.ts +44 -0
  108. package/src/shared/action-registry.ts +466 -0
  109. package/src/shared/brick-states.ts +91 -0
  110. package/src/shared/command-palette.ts +35 -0
  111. package/src/shared/components/announcement-bar.tsx +30 -0
  112. package/src/shared/components/app-confirm-dialog.tsx +29 -0
  113. package/src/shared/components/breadcrumb.tsx +21 -0
  114. package/src/shared/components/brick-gate.tsx +60 -0
  115. package/src/shared/components/command-output.tsx +95 -0
  116. package/src/shared/components/command-palette.tsx +97 -0
  117. package/src/shared/components/confirm-dialog.tsx +61 -0
  118. package/src/shared/components/diff-viewer.tsx +219 -0
  119. package/src/shared/components/empty-state.tsx +36 -0
  120. package/src/shared/components/error-bar.tsx +60 -0
  121. package/src/shared/components/error-boundary.tsx +53 -0
  122. package/src/shared/components/help-overlay.tsx +99 -0
  123. package/src/shared/components/identity-switcher.tsx +168 -0
  124. package/src/shared/components/loading-indicator.tsx +40 -0
  125. package/src/shared/components/pagination-bar.tsx +68 -0
  126. package/src/shared/components/pre-connection-screen.tsx +398 -0
  127. package/src/shared/components/scroll-indicator.tsx +46 -0
  128. package/src/shared/components/side-nav-utils.ts +68 -0
  129. package/src/shared/components/side-nav.tsx +287 -0
  130. package/src/shared/components/spinner.tsx +26 -0
  131. package/src/shared/components/status-bar.tsx +117 -0
  132. package/src/shared/components/styled-text.tsx +72 -0
  133. package/src/shared/components/sub-tab-bar-utils.ts +100 -0
  134. package/src/shared/components/sub-tab-bar.tsx +40 -0
  135. package/src/shared/components/tab-bar-utils.ts +36 -0
  136. package/src/shared/components/tab-bar.tsx +50 -0
  137. package/src/shared/components/text-input.tsx +73 -0
  138. package/src/shared/components/tooltip.tsx +53 -0
  139. package/src/shared/components/virtual-list.tsx +93 -0
  140. package/src/shared/components/welcome-screen.tsx +111 -0
  141. package/src/shared/hooks/use-api.ts +10 -0
  142. package/src/shared/hooks/use-brick-available.ts +42 -0
  143. package/src/shared/hooks/use-confirm.ts +66 -0
  144. package/src/shared/hooks/use-connection-state.ts +67 -0
  145. package/src/shared/hooks/use-copy.ts +31 -0
  146. package/src/shared/hooks/use-fresh-server.ts +62 -0
  147. package/src/shared/hooks/use-keyboard.ts +58 -0
  148. package/src/shared/hooks/use-list-navigation.ts +106 -0
  149. package/src/shared/hooks/use-swr.ts +117 -0
  150. package/src/shared/hooks/use-tab-fallback.ts +32 -0
  151. package/src/shared/hooks/use-text-input.ts +113 -0
  152. package/src/shared/hooks/use-visible-tabs.ts +61 -0
  153. package/src/shared/lib/circular-buffer.ts +82 -0
  154. package/src/shared/lib/clipboard.ts +14 -0
  155. package/src/shared/nav-items.ts +73 -0
  156. package/src/shared/navigation.ts +110 -0
  157. package/src/shared/status-breadcrumb.ts +74 -0
  158. package/src/shared/syntax-style.ts +3 -0
  159. package/src/shared/tab-visibility.ts +15 -0
  160. package/src/shared/text-style.ts +23 -0
  161. package/src/shared/theme.ts +179 -0
  162. package/src/shared/utils/format-size.ts +20 -0
  163. package/src/shared/utils/format-text.ts +10 -0
  164. package/src/shared/utils/format-time.ts +72 -0
  165. package/src/shared/utils/lru-cache.ts +75 -0
  166. package/src/stores/access-store-types.ts +154 -0
  167. package/src/stores/access-store.ts +674 -0
  168. package/src/stores/agents-store.ts +404 -0
  169. package/src/stores/announcement-store.ts +46 -0
  170. package/src/stores/api-console-store.ts +476 -0
  171. package/src/stores/connectors-store.ts +434 -0
  172. package/src/stores/create-api-action.ts +140 -0
  173. package/src/stores/delegation-store.ts +300 -0
  174. package/src/stores/error-store.ts +102 -0
  175. package/src/stores/events-store.ts +163 -0
  176. package/src/stores/files-store.ts +630 -0
  177. package/src/stores/first-run-store.ts +34 -0
  178. package/src/stores/global-store.ts +255 -0
  179. package/src/stores/infra-store.ts +461 -0
  180. package/src/stores/knowledge-store.ts +358 -0
  181. package/src/stores/lineage-store.ts +126 -0
  182. package/src/stores/mcp-store.ts +147 -0
  183. package/src/stores/payments-store.ts +545 -0
  184. package/src/stores/search-store-types.ts +155 -0
  185. package/src/stores/search-store.ts +656 -0
  186. package/src/stores/share-link-store.ts +151 -0
  187. package/src/stores/stack-store.ts +352 -0
  188. package/src/stores/ui-store.ts +161 -0
  189. package/src/stores/upload-store.ts +131 -0
  190. package/src/stores/versions-store.ts +355 -0
  191. package/src/stores/workflows-store.ts +402 -0
  192. package/src/stores/workspace-store.ts +185 -0
  193. package/src/stores/zones-store.ts +378 -0
@@ -0,0 +1,163 @@
1
+ /**
2
+ * Lineage sub-view for the Files panel.
3
+ * Shows upstream inputs, downstream dependents, and agent provenance.
4
+ * Issue #3417.
5
+ */
6
+
7
+ import React, { useEffect } from "react";
8
+ import crypto from "node:crypto";
9
+ import type { FileItem } from "../../stores/files-store.js";
10
+ import { useLineageStore } from "../../stores/lineage-store.js";
11
+ import { useApi } from "../../shared/hooks/use-api.js";
12
+ import { statusColor } from "../../shared/theme.js";
13
+
14
+ interface FileLineageProps {
15
+ readonly item: FileItem | null;
16
+ }
17
+
18
+ function computeUrn(item: FileItem): string | null {
19
+ if (!item.path) return null;
20
+ const zone = item.zoneId || "default";
21
+ const pathHash = crypto
22
+ .createHash("sha256")
23
+ .update(item.path)
24
+ .digest("hex")
25
+ .slice(0, 32);
26
+ return `urn:nexus:file:${zone}:${pathHash}`;
27
+ }
28
+
29
+ function truncate(value: string, max: number = 20): string {
30
+ return value.length > max ? `${value.slice(0, max - 1)}…` : value;
31
+ }
32
+
33
+ export function FileLineage({ item }: FileLineageProps): React.ReactNode {
34
+ const client = useApi();
35
+ const lineageCache = useLineageStore((s) => s.lineageCache);
36
+ const downstreamCache = useLineageStore((s) => s.downstreamCache);
37
+ const loading = useLineageStore((s) => s.loading);
38
+ const fetchLineage = useLineageStore((s) => s.fetchLineage);
39
+
40
+ const urn = item ? computeUrn(item) : null;
41
+
42
+ useEffect(() => {
43
+ if (client && urn && item?.path) {
44
+ fetchLineage(urn, item.path, client);
45
+ }
46
+ }, [client, urn, item?.path, fetchLineage]);
47
+
48
+ if (!item) {
49
+ return <text>No file selected</text>;
50
+ }
51
+
52
+ if (!urn) {
53
+ return <text>Cannot compute URN</text>;
54
+ }
55
+
56
+ if (loading && !lineageCache.has(urn)) {
57
+ return <text>Loading lineage...</text>;
58
+ }
59
+
60
+ const lineage = lineageCache.get(urn) ?? undefined;
61
+ const downstream = downstreamCache.get(urn) ?? [];
62
+ const hasLineage = lineage !== undefined && lineage !== null;
63
+ const hasDownstream = downstream.length > 0;
64
+
65
+ if (!hasLineage && !hasDownstream) {
66
+ return (
67
+ <box flexDirection="column" height="100%" width="100%">
68
+ <text>{"─── Lineage ───"}</text>
69
+ <text> </text>
70
+ <text>{" No lineage recorded for this file."}</text>
71
+ <text> </text>
72
+ <text foregroundColor={statusColor.dim}>
73
+ {" Agents declare lineage via scopes or"}
74
+ </text>
75
+ <text foregroundColor={statusColor.dim}>
76
+ {" PUT /api/v2/lineage/{urn}"}
77
+ </text>
78
+ </box>
79
+ );
80
+ }
81
+
82
+ return (
83
+ <box flexDirection="column" height="100%" width="100%">
84
+ <text>{"─── Lineage ───"}</text>
85
+
86
+ {/* Producer info */}
87
+ {hasLineage && lineage && (
88
+ <>
89
+ <box height={1} width="100%">
90
+ <text>
91
+ <span foregroundColor={statusColor.dim}>{"Agent "}</span>
92
+ <span foregroundColor={statusColor.identity}>{lineage.agent_id || "unknown"}</span>
93
+ </text>
94
+ </box>
95
+ <box height={1} width="100%">
96
+ <text>
97
+ <span foregroundColor={statusColor.dim}>{"Op "}</span>
98
+ <span>{lineage.operation || "write"}</span>
99
+ </text>
100
+ </box>
101
+ {lineage.agent_generation != null && (
102
+ <box height={1} width="100%">
103
+ <text>
104
+ <span foregroundColor={statusColor.dim}>{"Gen "}</span>
105
+ <span>{`#${lineage.agent_generation}`}</span>
106
+ </text>
107
+ </box>
108
+ )}
109
+ <text> </text>
110
+ </>
111
+ )}
112
+
113
+ {/* Upstream inputs */}
114
+ {hasLineage && lineage && lineage.upstream.length > 0 && (
115
+ <>
116
+ <text foregroundColor={statusColor.info}>
117
+ {`▸ Upstream (${lineage.upstream.length})${lineage.truncated ? " [truncated]" : ""}`}
118
+ </text>
119
+ {lineage.upstream.slice(0, 15).map((u, i) => (
120
+ <box key={`up-${i}`} height={1} width="100%">
121
+ <text>
122
+ <span foregroundColor={statusColor.reference}>{" "}{truncate(u.path, 34)}</span>
123
+ <span foregroundColor={statusColor.dim}>{` v${u.version}`}</span>
124
+ </text>
125
+ </box>
126
+ ))}
127
+ {lineage.upstream.length > 15 && (
128
+ <text foregroundColor={statusColor.dim}>
129
+ {` ... +${lineage.upstream.length - 15} more`}
130
+ </text>
131
+ )}
132
+ <text> </text>
133
+ </>
134
+ )}
135
+
136
+ {/* Downstream dependents */}
137
+ {hasDownstream && (
138
+ <>
139
+ <text foregroundColor={statusColor.info}>
140
+ {`▸ Downstream (${downstream.length})`}
141
+ </text>
142
+ {downstream.slice(0, 10).map((d, i) => (
143
+ <box key={`dn-${i}`} height={1} width="100%">
144
+ <text>
145
+ <span foregroundColor={statusColor.reference}>
146
+ {" "}{truncate(d.downstream_path || d.downstream_urn, 30)}
147
+ </span>
148
+ {d.agent_id && (
149
+ <span foregroundColor={statusColor.dim}>{` by ${d.agent_id}`}</span>
150
+ )}
151
+ </text>
152
+ </box>
153
+ ))}
154
+ {downstream.length > 10 && (
155
+ <text foregroundColor={statusColor.dim}>
156
+ {` ... +${downstream.length - 10} more`}
157
+ </text>
158
+ )}
159
+ </>
160
+ )}
161
+ </box>
162
+ );
163
+ }
@@ -0,0 +1,28 @@
1
+ /**
2
+ * Single row in the file list: icon + name + size + modified date.
3
+ *
4
+ * Wrapped with React.memo — re-renders only when item or selected changes.
5
+ * @see Issue #3102, Decisions 4A + 5A
6
+ */
7
+
8
+ import React from "react";
9
+ import type { FileItem } from "../../stores/files-store.js";
10
+ import { formatSize } from "../../shared/utils/format-size.js";
11
+
12
+ interface FileListItemProps {
13
+ readonly item: FileItem;
14
+ readonly selected: boolean;
15
+ }
16
+
17
+ export const FileListItem = React.memo(function FileListItem({ item, selected }: FileListItemProps): React.ReactNode {
18
+ const icon = item.isDirectory ? "📁" : "📄";
19
+ const prefix = selected ? "▸ " : " ";
20
+ const size = item.isDirectory ? "<DIR>" : formatSize(item.size);
21
+
22
+ return (
23
+ <box height={1} width="100%" flexDirection="row">
24
+ <text>{`${prefix}${icon} ${item.name}`}</text>
25
+ <text>{` ${size}`}</text>
26
+ </box>
27
+ );
28
+ });
@@ -0,0 +1,62 @@
1
+ /**
2
+ * File metadata sidebar displaying path, size, etag, version, owner, permissions, etc.
3
+ */
4
+
5
+ import React from "react";
6
+ import type { FileItem } from "../../stores/files-store.js";
7
+ import { textStyle } from "../../shared/text-style.js";
8
+ import { formatTimestamp } from "../../shared/utils/format-time.js";
9
+ import { statusColor } from "../../shared/theme.js";
10
+
11
+ interface FileMetadataProps {
12
+ readonly item: FileItem | null;
13
+ }
14
+
15
+ function formatBytes(bytes: number): string {
16
+ if (bytes === 0) return "0 B";
17
+ const units = ["B", "KB", "MB", "GB", "TB"];
18
+ const i = Math.floor(Math.log(bytes) / Math.log(1024));
19
+ return `${(bytes / Math.pow(1024, i)).toFixed(1)} ${units[i]}`;
20
+ }
21
+
22
+ function truncate(value: string | null | undefined, max: number = 30): string {
23
+ if (value === null || value === undefined) return "n/a";
24
+ return value.length > max ? `${value.slice(0, max - 1)}…` : value;
25
+ }
26
+
27
+ function MetaRow({ label, value, color }: { label: string; value: string; color?: string }): React.ReactNode {
28
+ return (
29
+ <box height={1} width="100%">
30
+ <text>
31
+ <span style={textStyle({ fg: statusColor.dim })}>{`${label.padEnd(8)} `}</span>
32
+ <span style={color ? textStyle({ fg: color }) : undefined}>{value}</span>
33
+ </text>
34
+ </box>
35
+ );
36
+ }
37
+
38
+ export function FileMetadata({ item }: FileMetadataProps): React.ReactNode {
39
+ if (!item) {
40
+ return (
41
+ <box height="100%" width="100%">
42
+ <text>No file selected</text>
43
+ </box>
44
+ );
45
+ }
46
+
47
+ return (
48
+ <box height="100%" width="100%" flexDirection="column">
49
+ <MetaRow label="Name" value={item.name} color={statusColor.info} />
50
+ <MetaRow label="Path" value={truncate(item.path, 40)} color={statusColor.reference} />
51
+ <MetaRow label="Type" value={item.isDirectory ? "Directory" : "File"} />
52
+ {!item.isDirectory && <MetaRow label="Size" value={formatBytes(item.size)} />}
53
+ <MetaRow label="ETag" value={truncate(item.etag, 20)} />
54
+ <MetaRow label="Version" value={truncate(item.version != null ? String(item.version) : null)} />
55
+ <MetaRow label="MIME" value={truncate(item.mimeType)} />
56
+ <MetaRow label="Owner" value={truncate(item.owner)} />
57
+ <MetaRow label="Perms" value={truncate(item.permissions)} />
58
+ <MetaRow label="Zone" value={item.zoneId ?? "n/a"} color={item.zoneId ? statusColor.reference : undefined} />
59
+ <MetaRow label="Modified" value={item.modifiedAt ? formatTimestamp(item.modifiedAt) : "n/a"} />
60
+ </box>
61
+ );
62
+ }
@@ -0,0 +1,108 @@
1
+ /**
2
+ * File content preview panel with syntax highlighting.
3
+ */
4
+
5
+ import React, { useEffect } from "react";
6
+ import { useFilesStore } from "../../stores/files-store.js";
7
+ import { useApi } from "../../shared/hooks/use-api.js";
8
+ import { Spinner } from "../../shared/components/spinner.js";
9
+ import { StyledText } from "../../shared/components/styled-text.js";
10
+ import { textStyle } from "../../shared/text-style.js";
11
+ import { defaultSyntaxStyle } from "../../shared/syntax-style.js";
12
+
13
+ export function FilePreview(): React.ReactNode {
14
+ const client = useApi();
15
+ const previewPath = useFilesStore((s) => s.previewPath);
16
+ const previewContent = useFilesStore((s) => s.previewContent);
17
+ const previewLoading = useFilesStore((s) => s.previewLoading);
18
+ const previewError = useFilesStore((s) => s.error);
19
+ const fetchPreview = useFilesStore((s) => s.fetchPreview);
20
+
21
+ useEffect(() => {
22
+ if (client && previewPath) {
23
+ fetchPreview(previewPath, client);
24
+ }
25
+ }, [client, previewPath, fetchPreview]);
26
+
27
+ if (!previewPath) {
28
+ return (
29
+ <box height="100%" width="100%" justifyContent="center" alignItems="center">
30
+ <text>Select a file to preview</text>
31
+ </box>
32
+ );
33
+ }
34
+
35
+ if (previewLoading) {
36
+ return (
37
+ <box height="100%" width="100%" justifyContent="center" alignItems="center">
38
+ <Spinner label={`Loading ${previewPath}...`} />
39
+ </box>
40
+ );
41
+ }
42
+
43
+ if (previewContent === null) {
44
+ return (
45
+ <box height="100%" width="100%" flexDirection="column">
46
+ <text>Unable to load preview</text>
47
+ {previewError && (
48
+ <text style={textStyle({ dim: true })}>{previewError}</text>
49
+ )}
50
+ </box>
51
+ );
52
+ }
53
+
54
+ // Detect file extension for syntax highlighting
55
+ const ext = previewPath.split(".").pop()?.toLowerCase() ?? "";
56
+ const language = extensionToLanguage(ext);
57
+
58
+ // Files that may contain ANSI escape sequences get rendered with StyledText
59
+ const ansiExtensions = new Set(["log", "out", "err", "ans", "ansi"]);
60
+ const hasAnsi = ansiExtensions.has(ext) || previewContent.includes("\x1b[");
61
+
62
+ if (hasAnsi) {
63
+ return (
64
+ <scrollbox height="100%" width="100%">
65
+ <StyledText>{previewContent}</StyledText>
66
+ </scrollbox>
67
+ );
68
+ }
69
+
70
+ // Use OpenTUI's Code component for syntax highlighting
71
+ return (
72
+ <scrollbox height="100%" width="100%">
73
+ <code content={previewContent} filetype={language} syntaxStyle={defaultSyntaxStyle} />
74
+ </scrollbox>
75
+ );
76
+ }
77
+
78
+ function extensionToLanguage(ext: string): string {
79
+ const map: Record<string, string> = {
80
+ ts: "typescript",
81
+ tsx: "tsx",
82
+ js: "javascript",
83
+ jsx: "jsx",
84
+ py: "python",
85
+ rs: "rust",
86
+ go: "go",
87
+ rb: "ruby",
88
+ java: "java",
89
+ c: "c",
90
+ cpp: "cpp",
91
+ h: "c",
92
+ hpp: "cpp",
93
+ json: "json",
94
+ yaml: "yaml",
95
+ yml: "yaml",
96
+ toml: "toml",
97
+ md: "markdown",
98
+ sh: "bash",
99
+ bash: "bash",
100
+ zsh: "bash",
101
+ sql: "sql",
102
+ html: "html",
103
+ css: "css",
104
+ xml: "xml",
105
+ proto: "protobuf",
106
+ };
107
+ return map[ext] ?? "text";
108
+ }
@@ -0,0 +1,89 @@
1
+ /**
2
+ * Schema sub-view for the Files panel.
3
+ * Shows extracted column schema for CSV/JSON/Parquet files.
4
+ * Issue #2930.
5
+ */
6
+
7
+ import React, { useEffect } from "react";
8
+ import type { FileItem } from "../../stores/files-store.js";
9
+ import { useKnowledgeStore } from "../../stores/knowledge-store.js";
10
+ import { useApi } from "../../shared/hooks/use-api.js";
11
+
12
+ interface FileSchemaProps {
13
+ readonly item: FileItem | null;
14
+ }
15
+
16
+ const DATA_EXTENSIONS = new Set([
17
+ "csv",
18
+ "tsv",
19
+ "json",
20
+ "jsonl",
21
+ "ndjson",
22
+ "parquet",
23
+ "pq",
24
+ ]);
25
+
26
+ function isDataFile(item: FileItem): boolean {
27
+ if (item.isDirectory) return false;
28
+ const ext = item.name.split(".").pop()?.toLowerCase() ?? "";
29
+ return DATA_EXTENSIONS.has(ext);
30
+ }
31
+
32
+ export function FileSchema({ item }: FileSchemaProps): React.ReactNode {
33
+ const client = useApi();
34
+ const schemaCache = useKnowledgeStore((s) => s.schemaCache);
35
+ const loading = useKnowledgeStore((s) => s.schemaLoading);
36
+ const fetchSchema = useKnowledgeStore((s) => s.fetchSchema);
37
+
38
+ useEffect(() => {
39
+ if (client && item && isDataFile(item)) {
40
+ fetchSchema(item.path, client);
41
+ }
42
+ }, [client, item, fetchSchema]);
43
+
44
+ if (!item) {
45
+ return <text>No file selected</text>;
46
+ }
47
+
48
+ if (!isDataFile(item)) {
49
+ return (
50
+ <box flexDirection="column" height="100%" width="100%">
51
+ <text>{"─── Schema ───"}</text>
52
+ <text>{"Not a data file (CSV/JSON/Parquet)"}</text>
53
+ </box>
54
+ );
55
+ }
56
+
57
+ if (loading) {
58
+ return <text>Extracting schema...</text>;
59
+ }
60
+
61
+ const schema = schemaCache.get(item.path);
62
+
63
+ if (schema === undefined) {
64
+ return <text>{"Schema not loaded yet"}</text>;
65
+ }
66
+
67
+ if (schema === null) {
68
+ return (
69
+ <box flexDirection="column" height="100%" width="100%">
70
+ <text>{"─── Schema ───"}</text>
71
+ <text>{"No schema available"}</text>
72
+ </box>
73
+ );
74
+ }
75
+
76
+ return (
77
+ <box flexDirection="column" height="100%" width="100%">
78
+ <text>{`─── Schema (${schema.format}) ───`}</text>
79
+ <text>{` Rows: ${schema.rowCount ?? "n/a"} Confidence: ${(schema.confidence * 100).toFixed(0)}%`}</text>
80
+ <text> </text>
81
+ <text>{" Column Type Nullable"}</text>
82
+ {schema.columns.map((col) => (
83
+ <text key={col.name}>
84
+ {` ${col.name.padEnd(20)} ${col.type.padEnd(12)} ${col.nullable}`}
85
+ </text>
86
+ ))}
87
+ </box>
88
+ );
89
+ }
@@ -0,0 +1,44 @@
1
+ /**
2
+ * Single tree node row: indent + expand/collapse icon + file/folder icon + name + size.
3
+ *
4
+ * Wrapped with React.memo — re-renders only when node, selected, or marked changes.
5
+ * Shows selection checkmark for multi-select (Decision 3A).
6
+ * @see Issue #3102, Decisions 4A + 5A
7
+ */
8
+
9
+ import React from "react";
10
+ import type { TreeNode } from "../../stores/files-store.js";
11
+ import { formatSize } from "../../shared/utils/format-size.js";
12
+
13
+ interface FileTreeNodeProps {
14
+ readonly node: TreeNode;
15
+ readonly selected: boolean;
16
+ /** Whether this node is in the current multi-selection set. */
17
+ readonly marked: boolean;
18
+ }
19
+
20
+ export const FileTreeNode = React.memo(function FileTreeNode({ node, selected, marked }: FileTreeNodeProps): React.ReactNode {
21
+ const indent = " ".repeat(node.depth);
22
+ const cursor = selected ? "▸ " : " ";
23
+ const check = marked ? "✓ " : " ";
24
+
25
+ let expandIcon = " ";
26
+ if (node.isDirectory) {
27
+ if (node.loading) {
28
+ expandIcon = "⟳ ";
29
+ } else if (node.expanded) {
30
+ expandIcon = "▾ ";
31
+ } else {
32
+ expandIcon = "▸ ";
33
+ }
34
+ }
35
+
36
+ const fileIcon = node.isDirectory ? "📁" : "📄";
37
+ const sizeSuffix = !node.isDirectory && node.size > 0 ? ` (${formatSize(node.size)})` : "";
38
+
39
+ return (
40
+ <box height={1} width="100%">
41
+ <text>{`${cursor}${check}${indent}${expandIcon}${fileIcon} ${node.name}${sizeSuffix}`}</text>
42
+ </box>
43
+ );
44
+ });
@@ -0,0 +1,169 @@
1
+ /**
2
+ * Recursive file tree with lazy-expand for directories.
3
+ * Flattens the visible tree, then uses VirtualList for windowed rendering.
4
+ * Auto-loads more children when the user scrolls near the end of a paginated directory.
5
+ *
6
+ * Supports client-side fuzzy filtering (Decision 4A, 13A)
7
+ * and rendering multi-selection state (Decision 3A).
8
+ *
9
+ * @see Issue #3102, Decisions 1A (virtualization) + 4A (React.memo on children)
10
+ */
11
+
12
+ import React, { useCallback, useEffect, useMemo } from "react";
13
+ import { useFilesStore, type TreeNode } from "../../stores/files-store.js";
14
+ import { useApi } from "../../shared/hooks/use-api.js";
15
+ import { FileTreeNode } from "./file-tree-node.js";
16
+ import { Spinner } from "../../shared/components/spinner.js";
17
+ import { ScrollIndicator } from "../../shared/components/scroll-indicator.js";
18
+ import { VirtualList } from "../../shared/components/virtual-list.js";
19
+
20
+ const VIEWPORT_HEIGHT = 20;
21
+
22
+ /** Sentinel value used to mark "load more" placeholder nodes in the flattened list. */
23
+ export const LOAD_MORE_SENTINEL = "__load_more__";
24
+
25
+ /** A synthetic TreeNode representing a "load more" placeholder. */
26
+ export function makeLoadMoreNode(parentPath: string, depth: number, loading: boolean): TreeNode {
27
+ return {
28
+ path: `${parentPath}/${LOAD_MORE_SENTINEL}`,
29
+ name: loading ? "Loading more..." : "▼ Load more...",
30
+ isDirectory: false,
31
+ expanded: false,
32
+ children: [],
33
+ loading: false,
34
+ depth,
35
+ size: 0,
36
+ nextCursor: null,
37
+ hasMore: false,
38
+ loadingMore: false,
39
+ };
40
+ }
41
+
42
+ interface FileTreeProps {
43
+ /** Client-side fuzzy filter query. Empty string = no filter. */
44
+ readonly filterQuery?: string;
45
+ /** Set of currently selected file paths (for multi-select rendering). */
46
+ readonly effectiveSelection?: ReadonlySet<string>;
47
+ }
48
+
49
+ export function FileTree({ filterQuery = "", effectiveSelection }: FileTreeProps): React.ReactNode {
50
+ const client = useApi();
51
+ const treeNodes = useFilesStore((s) => s.treeNodes);
52
+ const currentPath = useFilesStore((s) => s.currentPath);
53
+ const selectedIndex = useFilesStore((s) => s.selectedIndex);
54
+ const expandNode = useFilesStore((s) => s.expandNode);
55
+ const loadMoreChildren = useFilesStore((s) => s.loadMoreChildren);
56
+
57
+ // Initialize root on mount
58
+ useEffect(() => {
59
+ if (client && !treeNodes.has(currentPath)) {
60
+ expandNode(currentPath, client);
61
+ }
62
+ }, [client, currentPath, treeNodes, expandNode]);
63
+
64
+ // Flatten visible tree nodes, then apply filter (Decision 13A).
65
+ // Inserts "load more" sentinel nodes at the end of paginated directories.
66
+ const visibleNodes = useMemo(() => {
67
+ const all = flattenVisibleNodes(currentPath, treeNodes);
68
+ if (!filterQuery) return all;
69
+ const lowerQuery = filterQuery.toLowerCase();
70
+ return all.filter((node) => fuzzyMatch(node.name.toLowerCase(), lowerQuery));
71
+ }, [currentPath, treeNodes, filterQuery]);
72
+
73
+ // Auto-load more: when the selected node is a "load more" sentinel, trigger fetch
74
+ useEffect(() => {
75
+ if (!client) return;
76
+ const selectedNode = visibleNodes[selectedIndex];
77
+ if (!selectedNode || !selectedNode.path.endsWith(LOAD_MORE_SENTINEL)) return;
78
+
79
+ // Extract the parent path from the sentinel node
80
+ const parentPath = selectedNode.path.slice(0, -(LOAD_MORE_SENTINEL.length + 1));
81
+ const parentNode = treeNodes.get(parentPath);
82
+ if (parentNode?.hasMore && !parentNode.loadingMore) {
83
+ loadMoreChildren(parentPath, client);
84
+ }
85
+ }, [client, selectedIndex, visibleNodes, treeNodes, loadMoreChildren]);
86
+
87
+ // Stable render callback for VirtualList (avoids inline closure per-render)
88
+ const renderNode = useCallback(
89
+ (node: TreeNode, index: number) => (
90
+ <FileTreeNode
91
+ key={node.path}
92
+ node={node}
93
+ selected={index === selectedIndex}
94
+ marked={effectiveSelection?.has(node.path) ?? false}
95
+ />
96
+ ),
97
+ [selectedIndex, effectiveSelection],
98
+ );
99
+
100
+ if (!client) {
101
+ return <text>No connection configured</text>;
102
+ }
103
+
104
+ if (visibleNodes.length === 0) {
105
+ const rootNode = treeNodes.get(currentPath);
106
+ if (rootNode?.loading) {
107
+ return <Spinner label="Loading..." />;
108
+ }
109
+ return <text>{filterQuery ? "No matches" : "Empty directory"}</text>;
110
+ }
111
+
112
+ return (
113
+ <ScrollIndicator selectedIndex={selectedIndex} totalItems={visibleNodes.length} visibleItems={VIEWPORT_HEIGHT}>
114
+ <VirtualList
115
+ items={visibleNodes}
116
+ renderItem={renderNode}
117
+ viewportHeight={VIEWPORT_HEIGHT}
118
+ selectedIndex={selectedIndex}
119
+ overscan={5}
120
+ />
121
+ </ScrollIndicator>
122
+ );
123
+ }
124
+
125
+ /** Simple fuzzy match: all characters of query appear in order in target. */
126
+ function fuzzyMatch(target: string, query: string): boolean {
127
+ let qi = 0;
128
+ for (let ti = 0; ti < target.length && qi < query.length; ti++) {
129
+ if (target[ti] === query[qi]) qi++;
130
+ }
131
+ return qi === query.length;
132
+ }
133
+
134
+ /**
135
+ * Flatten tree into ordered list of visible nodes (expanded directories show children).
136
+ * Appends a synthetic "load more" node after the last child of any directory with hasMore.
137
+ */
138
+ export function flattenVisibleNodes(
139
+ rootPath: string,
140
+ nodes: ReadonlyMap<string, TreeNode>,
141
+ ): readonly TreeNode[] {
142
+ const result: TreeNode[] = [];
143
+ const root = nodes.get(rootPath);
144
+ if (!root) return result;
145
+
146
+ function walk(nodePath: string): void {
147
+ const node = nodes.get(nodePath);
148
+ if (!node) return;
149
+
150
+ // Don't include the root itself — only its children
151
+ if (nodePath !== rootPath) {
152
+ result.push(node);
153
+ }
154
+
155
+ if (node.expanded) {
156
+ for (const childPath of node.children) {
157
+ walk(childPath);
158
+ }
159
+
160
+ // If this directory has more pages, show a "load more" placeholder
161
+ if (node.hasMore) {
162
+ result.push(makeLoadMoreNode(nodePath, node.depth + 1, node.loadingMore));
163
+ }
164
+ }
165
+ }
166
+
167
+ walk(rootPath);
168
+ return result;
169
+ }