@anchrd/intel 0.59.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 +496 -0
- package/bin/intel.mjs +134 -0
- package/dist/adapters/cloudflare/cloudflare-flow-workflow.d.ts +42 -0
- package/dist/adapters/cloudflare/cloudflare-flow-workflow.js +89 -0
- package/dist/adapters/cloudflare/cloudflare.d.ts +29 -0
- package/dist/adapters/cloudflare/cloudflare.js +416 -0
- package/dist/adapters/cloudflare/cloudflare.types.d.ts +31 -0
- package/dist/adapters/cloudflare/cloudflare.types.js +1 -0
- package/dist/adapters/content/content.d.ts +3 -0
- package/dist/adapters/content/content.js +128 -0
- package/dist/adapters/content/content.types.d.ts +38 -0
- package/dist/adapters/content/content.types.js +1 -0
- package/dist/adapters/db/db-audit.d.ts +62 -0
- package/dist/adapters/db/db-audit.js +150 -0
- package/dist/adapters/db/db-boards.d.ts +6 -0
- package/dist/adapters/db/db-boards.js +324 -0
- package/dist/adapters/db/db-feed.d.ts +30 -0
- package/dist/adapters/db/db-feed.js +276 -0
- package/dist/adapters/db/db-flows.d.ts +42 -0
- package/dist/adapters/db/db-flows.js +1311 -0
- package/dist/adapters/db/db-grants.d.ts +133 -0
- package/dist/adapters/db/db-grants.js +269 -0
- package/dist/adapters/db/db-indexing.d.ts +3 -0
- package/dist/adapters/db/db-indexing.js +200 -0
- package/dist/adapters/db/db-oauth.d.ts +3 -0
- package/dist/adapters/db/db-oauth.js +26 -0
- package/dist/adapters/db/db-prompts.d.ts +46 -0
- package/dist/adapters/db/db-prompts.js +144 -0
- package/dist/adapters/db/db.d.ts +6 -0
- package/dist/adapters/db/db.js +1687 -0
- package/dist/adapters/db/db.types.d.ts +16 -0
- package/dist/adapters/db/db.types.js +1 -0
- package/dist/adapters/document-converter/document-converter.d.ts +2 -0
- package/dist/adapters/document-converter/document-converter.js +26 -0
- package/dist/adapters/document-converter/document-converter.types.d.ts +17 -0
- package/dist/adapters/document-converter/document-converter.types.js +1 -0
- package/dist/adapters/flow-runtime/flow-runtime.d.ts +3 -0
- package/dist/adapters/flow-runtime/flow-runtime.js +22 -0
- package/dist/adapters/flow-runtime/flow-runtime.types.d.ts +19 -0
- package/dist/adapters/flow-runtime/flow-runtime.types.js +1 -0
- package/dist/adapters/index-queue/index-queue.d.ts +10 -0
- package/dist/adapters/index-queue/index-queue.js +12 -0
- package/dist/adapters/index-queue/index-queue.types.d.ts +11 -0
- package/dist/adapters/index-queue/index-queue.types.js +1 -0
- package/dist/adapters/json-schema/json-schema.d.ts +2 -0
- package/dist/adapters/json-schema/json-schema.js +77 -0
- package/dist/adapters/openid/openid.d.ts +5 -0
- package/dist/adapters/openid/openid.js +301 -0
- package/dist/adapters/portal-tokens/portal-tokens.d.ts +8 -0
- package/dist/adapters/portal-tokens/portal-tokens.js +79 -0
- package/dist/adapters/remote-tools/remote-tools.d.ts +5 -0
- package/dist/adapters/remote-tools/remote-tools.js +129 -0
- package/dist/adapters/semantic-index/semantic-index.d.ts +2 -0
- package/dist/adapters/semantic-index/semantic-index.js +139 -0
- package/dist/adapters/semantic-index/semantic-index.types.d.ts +56 -0
- package/dist/adapters/semantic-index/semantic-index.types.js +1 -0
- package/dist/adapters/session-cookie/session-cookie.d.ts +5 -0
- package/dist/adapters/session-cookie/session-cookie.js +87 -0
- package/dist/adapters/tool-source-policy/tool-source-policy.d.ts +1 -0
- package/dist/adapters/tool-source-policy/tool-source-policy.js +22 -0
- package/dist/audit/audit.d.ts +2 -0
- package/dist/audit/audit.js +87 -0
- package/dist/audit/audit.types.d.ts +23 -0
- package/dist/audit/audit.types.js +1 -0
- package/dist/auth/auth.d.ts +2 -0
- package/dist/auth/auth.js +284 -0
- package/dist/auth/auth.types.d.ts +108 -0
- package/dist/auth/auth.types.js +1 -0
- package/dist/boards/boards.d.ts +44 -0
- package/dist/boards/boards.js +447 -0
- package/dist/boards/boards.types.d.ts +117 -0
- package/dist/boards/boards.types.js +1 -0
- package/dist/build/build.d.ts +2 -0
- package/dist/build/build.js +192 -0
- package/dist/build/build.types.d.ts +17 -0
- package/dist/build/build.types.js +1 -0
- package/dist/bundle/bundle.d.ts +4 -0
- package/dist/bundle/bundle.js +1194 -0
- package/dist/bundle/bundle.types.d.ts +33 -0
- package/dist/bundle/bundle.types.js +1 -0
- package/dist/cli/cli.d.ts +4 -0
- package/dist/cli/cli.js +262 -0
- package/dist/cli/cli.types.d.ts +14 -0
- package/dist/cli/cli.types.js +1 -0
- package/dist/contract/audit.d.ts +50 -0
- package/dist/contract/audit.js +72 -0
- package/dist/contract/board.d.ts +150 -0
- package/dist/contract/board.js +262 -0
- package/dist/contract/bundle.d.ts +147 -0
- package/dist/contract/bundle.js +139 -0
- package/dist/contract/contract.d.ts +112 -0
- package/dist/contract/contract.js +143 -0
- package/dist/contract/feed.d.ts +126 -0
- package/dist/contract/feed.js +149 -0
- package/dist/contract/flow-run.d.ts +346 -0
- package/dist/contract/flow-run.js +194 -0
- package/dist/contract/flow.d.ts +1057 -0
- package/dist/contract/flow.js +552 -0
- package/dist/contract/node.d.ts +551 -0
- package/dist/contract/node.js +456 -0
- package/dist/contract/share.d.ts +216 -0
- package/dist/contract/share.js +134 -0
- package/dist/contract/table.d.ts +171 -0
- package/dist/contract/table.js +117 -0
- package/dist/contract/tool.d.ts +122 -0
- package/dist/contract/tool.js +172 -0
- package/dist/feed/feed.d.ts +2 -0
- package/dist/feed/feed.js +77 -0
- package/dist/feed/feed.types.d.ts +23 -0
- package/dist/feed/feed.types.js +1 -0
- package/dist/flows/flows.d.ts +41 -0
- package/dist/flows/flows.js +2216 -0
- package/dist/flows/flows.types.d.ts +339 -0
- package/dist/flows/flows.types.js +1 -0
- package/dist/http/http.d.ts +9 -0
- package/dist/http/http.js +874 -0
- package/dist/http/http.types.d.ts +22 -0
- package/dist/http/http.types.js +1 -0
- package/dist/indexing/indexing.d.ts +6 -0
- package/dist/indexing/indexing.js +239 -0
- package/dist/indexing/indexing.types.d.ts +11 -0
- package/dist/indexing/indexing.types.js +1 -0
- package/dist/intel/intel.d.ts +3 -0
- package/dist/intel/intel.js +194 -0
- package/dist/intel/intel.types.d.ts +24 -0
- package/dist/intel/intel.types.js +1 -0
- package/dist/mcp/mcp.d.ts +2 -0
- package/dist/mcp/mcp.js +1292 -0
- package/dist/mcp/mcp.types.d.ts +25 -0
- package/dist/mcp/mcp.types.js +1 -0
- package/dist/nodes/document-links/document-links.d.ts +20 -0
- package/dist/nodes/document-links/document-links.js +57 -0
- package/dist/nodes/nodes.d.ts +2 -0
- package/dist/nodes/nodes.js +1667 -0
- package/dist/nodes/nodes.types.d.ts +511 -0
- package/dist/nodes/nodes.types.js +1 -0
- package/dist/prepare/prepare.d.ts +4 -0
- package/dist/prepare/prepare.js +16 -0
- package/dist/prepare/prepare.types.d.ts +9 -0
- package/dist/prepare/prepare.types.js +1 -0
- package/dist/prompts/prompts.d.ts +2 -0
- package/dist/prompts/prompts.js +65 -0
- package/dist/prompts/prompts.types.d.ts +71 -0
- package/dist/prompts/prompts.types.js +1 -0
- package/dist/shared/callers/callers.d.ts +28 -0
- package/dist/shared/callers/callers.js +30 -0
- package/dist/shared/csv/csv.d.ts +13 -0
- package/dist/shared/csv/csv.js +85 -0
- package/dist/shared/document-text/document-text.d.ts +21 -0
- package/dist/shared/document-text/document-text.js +31 -0
- package/dist/shared/gate-authorization/gate-authorization.d.ts +5 -0
- package/dist/shared/gate-authorization/gate-authorization.js +26 -0
- package/dist/shared/grant-expiry/grant-expiry.d.ts +19 -0
- package/dist/shared/grant-expiry/grant-expiry.js +26 -0
- package/dist/shared/intel-error/intel-error.d.ts +22 -0
- package/dist/shared/intel-error/intel-error.js +27 -0
- package/dist/shared/plain-title/plain-title.d.ts +16 -0
- package/dist/shared/plain-title/plain-title.js +45 -0
- package/dist/shared/problem-details/problem-details.d.ts +30 -0
- package/dist/shared/problem-details/problem-details.js +18 -0
- package/dist/shared/report-unexpected-error/report-unexpected-error.d.ts +1 -0
- package/dist/shared/report-unexpected-error/report-unexpected-error.js +37 -0
- package/dist/shared/safe-return-path/safe-return-path.d.ts +2 -0
- package/dist/shared/safe-return-path/safe-return-path.js +11 -0
- package/dist/shared/sha256/sha256.d.ts +2 -0
- package/dist/shared/sha256/sha256.js +14 -0
- package/dist/tools/tool-servers/tool-servers.d.ts +46 -0
- package/dist/tools/tool-servers/tool-servers.js +177 -0
- package/dist/tools/tools.d.ts +2 -0
- package/dist/tools/tools.js +207 -0
- package/dist/tools/tools.types.d.ts +56 -0
- package/dist/tools/tools.types.js +1 -0
- package/examples/branding/favicon.svg +5 -0
- package/examples/branding/logo.svg +5 -0
- package/examples/branding/theme.css +16 -0
- package/examples/dev.vars.example +7 -0
- package/examples/intel.json +9 -0
- package/migrations/0000_intel_foundation.sql +203 -0
- package/migrations/0001_portal_native_tools.sql +21 -0
- package/migrations/0002_flows_in_the_knowledge_tree.sql +34 -0
- package/migrations/0003_folder_permissions.sql +211 -0
- package/migrations/0004_subflow_runs.sql +14 -0
- package/migrations/0005_flow_node_cleanup.sql +28 -0
- package/migrations/0005_tables_in_the_knowledge_tree.sql +68 -0
- package/migrations/0006_links_are_written_in_the_text.sql +20 -0
- package/migrations/0007_no_node_waits.sql +69 -0
- package/migrations/0008_three_layers.sql +130 -0
- package/migrations/0009_no_context_policy.sql +48 -0
- package/migrations/0010_no_run_waits_a_year.sql +27 -0
- package/migrations/0011_one_name_for_the_tree.sql +53 -0
- package/migrations/0012_table_snapshots.sql +29 -0
- package/migrations/0017_a_vector_per_card.sql +38 -0
- package/migrations/0018_no_context_policy_at_last.sql +97 -0
- package/migrations/0019_one_name_for_the_grants.sql +52 -0
- package/migrations/0020_cascade_purge_replay.sql +11 -0
- package/migrations/0021_a_flow_carries_its_own_grant.sql +44 -0
- package/migrations/0022_a_cursor_over_the_journal.sql +14 -0
- package/migrations/0023_a_board_and_its_tasks.sql +116 -0
- package/migrations/0024_the_archive_is_a_column.sql +33 -0
- package/migrations/0025_archiving_takes_the_subtree.sql +29 -0
- package/migrations/0026_one_feed_over_two_kinds.sql +23 -0
- package/migrations/0027_the_runs_of_every_flow.sql +27 -0
- package/migrations/0028_a_prompt_name_over_two_kinds.sql +70 -0
- package/package.json +118 -0
- package/src/contract/CLAUDE.md +152 -0
- package/src/contract/README.md +102 -0
- package/src/contract/audit.ts +85 -0
- package/src/contract/board.ts +306 -0
- package/src/contract/bundle.ts +157 -0
- package/src/contract/contract.ts +161 -0
- package/src/contract/feed.ts +165 -0
- package/src/contract/flow-run.ts +241 -0
- package/src/contract/flow.ts +675 -0
- package/src/contract/node.ts +583 -0
- package/src/contract/share.ts +196 -0
- package/src/contract/table.ts +178 -0
- package/src/contract/tool.ts +202 -0
- package/ui/README.md +110 -0
- package/ui/components.json +21 -0
- package/ui/index.html +13 -0
- package/ui/package.json +11 -0
- package/ui/scripts/lint-tokens.mjs +187 -0
- package/ui/src/access-summary/access-summary.tsx +385 -0
- package/ui/src/app/action-slot/action-slot.tsx +27 -0
- package/ui/src/app/app-sidebar/app-sidebar.tsx +71 -0
- package/ui/src/app/app-tree/app-tree.tsx +1175 -0
- package/ui/src/app/app.tsx +87 -0
- package/ui/src/app/header-search/header-search.tsx +294 -0
- package/ui/src/app/reindex-dialog/reindex-dialog.tsx +79 -0
- package/ui/src/app/settings-dialog/settings-dialog.tsx +135 -0
- package/ui/src/app/sidebar-preferences/sidebar-preferences.ts +68 -0
- package/ui/src/app/sidebar-preferences/sidebar-preferences.types.ts +15 -0
- package/ui/src/app/sidebar-resize-handle/sidebar-resize-handle.tsx +86 -0
- package/ui/src/app/tree-expansion/tree-expansion.ts +107 -0
- package/ui/src/app/tree-expansion/tree-expansion.types.ts +31 -0
- package/ui/src/app/tree-move/tree-move.tsx +427 -0
- package/ui/src/app/tree-reveal/tree-reveal.ts +64 -0
- package/ui/src/app/tree-reveal/tree-reveal.types.ts +16 -0
- package/ui/src/app/user-footer/user-footer.tsx +188 -0
- package/ui/src/app/view-menu/view-menu.tsx +115 -0
- package/ui/src/app-root/app-root.tsx +109 -0
- package/ui/src/app-root/app-root.types.ts +27 -0
- package/ui/src/archive/archive.tsx +417 -0
- package/ui/src/attachment-detail/attachment-detail.ts +52 -0
- package/ui/src/attachment-viewer/attachment-viewer.tsx +166 -0
- package/ui/src/blocknote-view/blocknote-view.tsx +30 -0
- package/ui/src/board/board-assignee/board-assignee-picker.tsx +132 -0
- package/ui/src/board/board-assignee/board-assignee.ts +48 -0
- package/ui/src/board/board-assignee/board-assignee.tsx +75 -0
- package/ui/src/board/board-chip/board-chip.tsx +46 -0
- package/ui/src/board/board-crumbs/board-crumbs.ts +56 -0
- package/ui/src/board/board-crumbs/board-crumbs.tsx +111 -0
- package/ui/src/board/board-data/board-data.ts +195 -0
- package/ui/src/board/board-data/board-data.types.ts +38 -0
- package/ui/src/board/board-dates/board-dates.ts +40 -0
- package/ui/src/board/board-dates/board-dates.tsx +105 -0
- package/ui/src/board/board-kanban/board-kanban.ts +162 -0
- package/ui/src/board/board-kanban/board-kanban.tsx +680 -0
- package/ui/src/board/board-open-task/board-open-task.ts +32 -0
- package/ui/src/board/board-panel/board-panel.tsx +259 -0
- package/ui/src/board/board-settings/board-settings.tsx +226 -0
- package/ui/src/board/board-status/board-status.tsx +60 -0
- package/ui/src/board/board-stripes/board-stripes.ts +128 -0
- package/ui/src/board/board-table/board-table.ts +53 -0
- package/ui/src/board/board-table/board-table.tsx +610 -0
- package/ui/src/board/board-task/board-task.ts +108 -0
- package/ui/src/board/board-task/board-task.tsx +335 -0
- package/ui/src/board/board-title-row/board-title-row.tsx +66 -0
- package/ui/src/branding/branding.tsx +17 -0
- package/ui/src/branding/custom-logo.ts +1 -0
- package/ui/src/branding/favicon.default.svg +5 -0
- package/ui/src/branding/favicon.svg +5 -0
- package/ui/src/components/ui/avatar.tsx +39 -0
- package/ui/src/components/ui/button.tsx +64 -0
- package/ui/src/components/ui/collapsible.tsx +20 -0
- package/ui/src/components/ui/command.tsx +160 -0
- package/ui/src/components/ui/dialog.tsx +143 -0
- package/ui/src/components/ui/dropdown-menu.tsx +187 -0
- package/ui/src/components/ui/input.tsx +21 -0
- package/ui/src/components/ui/popover.tsx +41 -0
- package/ui/src/components/ui/select.tsx +163 -0
- package/ui/src/components/ui/separator.tsx +26 -0
- package/ui/src/components/ui/sheet.tsx +136 -0
- package/ui/src/components/ui/sidebar.tsx +693 -0
- package/ui/src/components/ui/skeleton.tsx +13 -0
- package/ui/src/components/ui/switch.tsx +25 -0
- package/ui/src/components/ui/table.tsx +82 -0
- package/ui/src/components/ui/tabs.tsx +52 -0
- package/ui/src/components/ui/tooltip.tsx +51 -0
- package/ui/src/data/intel-data-provider/intel-data-provider.ts +685 -0
- package/ui/src/data/intel-data-provider/intel-data-provider.types.ts +266 -0
- package/ui/src/data/query-client.ts +18 -0
- package/ui/src/data/request-refusal/refusal-notice.tsx +36 -0
- package/ui/src/data/request-refusal/request-refusal.ts +51 -0
- package/ui/src/data/sign-in/sign-in.ts +97 -0
- package/ui/src/data/sign-in/sign-in.types.ts +15 -0
- package/ui/src/document-link/document-link.tsx +114 -0
- package/ui/src/document-markdown/document-markdown.ts +195 -0
- package/ui/src/editor-schema/editor-schema.ts +23 -0
- package/ui/src/entry-picker/entry-picker.tsx +238 -0
- package/ui/src/feed/feed.tsx +344 -0
- package/ui/src/file-preview/file-preview-view.tsx +496 -0
- package/ui/src/file-preview/file-preview.tsx +28 -0
- package/ui/src/file-preview/pdf-file-preview.tsx +5 -0
- package/ui/src/file-preview/presentation-file-preview.tsx +21 -0
- package/ui/src/file-preview/spreadsheet-file-preview.tsx +5 -0
- package/ui/src/file-preview/word-file-preview.tsx +5 -0
- package/ui/src/flow-runs/flow-runs.tsx +238 -0
- package/ui/src/flows/flows.tsx +1646 -0
- package/ui/src/flows/node-icon/node-icon.ts +34 -0
- package/ui/src/flows/node-palette/node-palette.tsx +214 -0
- package/ui/src/flows/node-palette/node-palette.types.ts +15 -0
- package/ui/src/folder-contents/folder-contents.tsx +321 -0
- package/ui/src/folder-index/folder-index.ts +55 -0
- package/ui/src/frontmatter/frontmatter.tsx +193 -0
- package/ui/src/graph-pane/graph-pane.tsx +53 -0
- package/ui/src/hooks/use-mobile.ts +19 -0
- package/ui/src/i18n/custom.json +4 -0
- package/ui/src/i18n/de.json +609 -0
- package/ui/src/i18n/en.json +609 -0
- package/ui/src/i18n/es.json +609 -0
- package/ui/src/i18n/i18n-context.tsx +65 -0
- package/ui/src/i18n/i18n-languages/i18n-languages.ts +83 -0
- package/ui/src/i18n/i18n.ts +32 -0
- package/ui/src/i18n/i18n.types.ts +49 -0
- package/ui/src/kind-icon.ts +97 -0
- package/ui/src/lib/utils.ts +6 -0
- package/ui/src/main.tsx +68 -0
- package/ui/src/modal/modal.tsx +53 -0
- package/ui/src/node-details/node-details.tsx +545 -0
- package/ui/src/node-editor/node-editor.tsx +304 -0
- package/ui/src/node-graph/graph-notice.tsx +21 -0
- package/ui/src/node-graph/node-graph.ts +214 -0
- package/ui/src/node-graph/node-graph.tsx +295 -0
- package/ui/src/node-import/node-import.tsx +418 -0
- package/ui/src/node-save-error.ts +46 -0
- package/ui/src/node-source/node-source.tsx +128 -0
- package/ui/src/node-table/node-table.tsx +107 -0
- package/ui/src/nodes/nodes.tsx +240 -0
- package/ui/src/rename-resource/rename-resource.ts +80 -0
- package/ui/src/resolved-names/resolved-names.tsx +67 -0
- package/ui/src/resource-error.ts +33 -0
- package/ui/src/resource-menu/resource-menu.tsx +1156 -0
- package/ui/src/router/router-context.ts +6 -0
- package/ui/src/router/router.tsx +67 -0
- package/ui/src/router/router.types.ts +8 -0
- package/ui/src/router/selection-search.ts +88 -0
- package/ui/src/save-button/save-button.tsx +94 -0
- package/ui/src/section-hint/section-hint.tsx +40 -0
- package/ui/src/sign-in-refused/sign-in-refused.tsx +35 -0
- package/ui/src/styles.css +189 -0
- package/ui/src/table-columns/table-columns.tsx +379 -0
- package/ui/src/theme/custom.css +1 -0
- package/ui/src/theme/theme-context.tsx +89 -0
- package/ui/src/theme/theme.ts +70 -0
- package/ui/src/time/relative-time.tsx +41 -0
- package/ui/src/time/time-context.tsx +78 -0
- package/ui/src/time/time.ts +165 -0
- package/ui/src/title-row/title-row.tsx +185 -0
- package/ui/src/tools/tools.tsx +546 -0
- package/ui/src/user-name/user-name.ts +61 -0
- package/ui/src/verbatim/verbatim.tsx +68 -0
- package/ui/tsconfig.json +17 -0
- package/ui/vite.config.ts +129 -0
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
export interface SidebarPreferences {
|
|
2
|
+
open: boolean;
|
|
3
|
+
width: number;
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
export interface SidebarPreferenceStore {
|
|
7
|
+
read(): SidebarPreferences;
|
|
8
|
+
write(preferences: SidebarPreferences): void;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export interface SidebarPreferenceDeps {
|
|
12
|
+
// A `null` storage disables persistence. `undefined` means "use the browser's", which is not the
|
|
13
|
+
// same thing: a test passes null, a browser without localStorage produces null on its own.
|
|
14
|
+
storage?: Pick<Storage, "getItem" | "setItem"> | null;
|
|
15
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { useRef } from "react";
|
|
2
|
+
import {
|
|
3
|
+
clampSidebarWidth,
|
|
4
|
+
MaxSidebarWidth,
|
|
5
|
+
MinSidebarWidth,
|
|
6
|
+
} from "@/app/sidebar-preferences/sidebar-preferences.ts";
|
|
7
|
+
import { cn } from "@/lib/utils";
|
|
8
|
+
|
|
9
|
+
const KeyboardStep = 16;
|
|
10
|
+
|
|
11
|
+
// The registry has no resizable sidebar, so this is the one piece of the shell that is not a
|
|
12
|
+
// primitive. It is an ARIA window splitter rather than a bare div: a drag-only handle exists for
|
|
13
|
+
// nobody on a keyboard, and the tree behind it is the entire navigation.
|
|
14
|
+
export function SidebarResizeHandle({
|
|
15
|
+
width,
|
|
16
|
+
label,
|
|
17
|
+
onWidthChange,
|
|
18
|
+
}: {
|
|
19
|
+
width: number;
|
|
20
|
+
label: string;
|
|
21
|
+
onWidthChange(width: number): void;
|
|
22
|
+
}) {
|
|
23
|
+
const drag = useRef<{ pointerId: number; startX: number; startWidth: number } | null>(null);
|
|
24
|
+
|
|
25
|
+
return (
|
|
26
|
+
// The WAI-ARIA window-splitter pattern is a focusable separator carrying a value. The rule's
|
|
27
|
+
// suggested `<hr>` cannot take focus, which would remove the keyboard operation this handle
|
|
28
|
+
// exists to provide.
|
|
29
|
+
// biome-ignore lint/a11y/useSemanticElements: a focusable window splitter cannot be an <hr>
|
|
30
|
+
<button
|
|
31
|
+
type="button"
|
|
32
|
+
role="separator"
|
|
33
|
+
aria-orientation="vertical"
|
|
34
|
+
aria-label={label}
|
|
35
|
+
aria-valuenow={width}
|
|
36
|
+
aria-valuemin={MinSidebarWidth}
|
|
37
|
+
aria-valuemax={MaxSidebarWidth}
|
|
38
|
+
className={cn(
|
|
39
|
+
"absolute inset-y-0 -right-1 z-20 hidden w-2 cursor-col-resize outline-none md:block",
|
|
40
|
+
"after:absolute after:inset-y-0 after:left-1/2 after:w-px after:-translate-x-1/2",
|
|
41
|
+
"hover:after:bg-sidebar-ring focus-visible:after:bg-sidebar-ring",
|
|
42
|
+
"focus-visible:ring-2 focus-visible:ring-sidebar-ring",
|
|
43
|
+
// Collapsed there is no sidebar left to drag. The shell already unmounts the whole panel
|
|
44
|
+
// then; this keeps the handle from reappearing should it ever be mounted collapsed.
|
|
45
|
+
"group-data-[state=collapsed]:hidden",
|
|
46
|
+
)}
|
|
47
|
+
onPointerDown={(event) => {
|
|
48
|
+
drag.current = {
|
|
49
|
+
pointerId: event.pointerId,
|
|
50
|
+
startX: event.clientX,
|
|
51
|
+
startWidth: width,
|
|
52
|
+
};
|
|
53
|
+
event.currentTarget.setPointerCapture(event.pointerId);
|
|
54
|
+
}}
|
|
55
|
+
onPointerMove={(event) => {
|
|
56
|
+
const active = drag.current;
|
|
57
|
+
if (!active || active.pointerId !== event.pointerId) return;
|
|
58
|
+
// Measured against the width the drag started from, never against the live value: reading
|
|
59
|
+
// back the clamped width would make the handle drift away from the cursor at the limits.
|
|
60
|
+
onWidthChange(clampSidebarWidth(active.startWidth + (event.clientX - active.startX)));
|
|
61
|
+
}}
|
|
62
|
+
onPointerUp={(event) => {
|
|
63
|
+
drag.current = null;
|
|
64
|
+
event.currentTarget.releasePointerCapture(event.pointerId);
|
|
65
|
+
}}
|
|
66
|
+
onPointerCancel={() => {
|
|
67
|
+
drag.current = null;
|
|
68
|
+
}}
|
|
69
|
+
onKeyDown={(event) => {
|
|
70
|
+
const next =
|
|
71
|
+
event.key === "ArrowLeft"
|
|
72
|
+
? width - KeyboardStep
|
|
73
|
+
: event.key === "ArrowRight"
|
|
74
|
+
? width + KeyboardStep
|
|
75
|
+
: event.key === "Home"
|
|
76
|
+
? MinSidebarWidth
|
|
77
|
+
: event.key === "End"
|
|
78
|
+
? MaxSidebarWidth
|
|
79
|
+
: null;
|
|
80
|
+
if (next === null) return;
|
|
81
|
+
event.preventDefault();
|
|
82
|
+
onWidthChange(clampSidebarWidth(next));
|
|
83
|
+
}}
|
|
84
|
+
/>
|
|
85
|
+
);
|
|
86
|
+
}
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
ExpandedLevel,
|
|
3
|
+
TreeExpansionDeps,
|
|
4
|
+
TreeExpansionState,
|
|
5
|
+
TreeExpansionStore,
|
|
6
|
+
} from "./tree-expansion.types.ts";
|
|
7
|
+
|
|
8
|
+
const storageKey = "intel.tree";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* How many open levels travel across a reload.
|
|
12
|
+
*
|
|
13
|
+
* ⚠️ NOT a guess about how many folders somebody opens: the written list is already trimmed to what
|
|
14
|
+
* the tree still draws, so it cannot grow on its own. The cap is against a stored value nobody
|
|
15
|
+
* wrote — a hand-edited or corrupted entry would otherwise become that many level queries at the
|
|
16
|
+
* next start, because every remembered level is one query (#11).
|
|
17
|
+
*/
|
|
18
|
+
const MaxRememberedLevels = 200;
|
|
19
|
+
|
|
20
|
+
function browserStorage(): Pick<Storage, "getItem" | "setItem"> | null {
|
|
21
|
+
// Reading `localStorage` throws outright in a few privacy modes, so the guard is a try, not a
|
|
22
|
+
// typeof check. Losing which folders were open is acceptable; losing the tree is not.
|
|
23
|
+
try {
|
|
24
|
+
return typeof window === "undefined" ? null : window.localStorage;
|
|
25
|
+
} catch {
|
|
26
|
+
return null;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const empty: TreeExpansionState = { levels: [], revealed: null };
|
|
31
|
+
|
|
32
|
+
function levelFrom(value: unknown): ExpandedLevel | null {
|
|
33
|
+
if (typeof value !== "object" || value === null) return null;
|
|
34
|
+
const { id, type } = value as Partial<ExpandedLevel>;
|
|
35
|
+
if (typeof id !== "string" || id.length === 0) return null;
|
|
36
|
+
return type === "folder" || type === "flow" ? { id, type } : null;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Which levels of the tree stand open, written through so the answer survives what the component
|
|
41
|
+
* does not.
|
|
42
|
+
*
|
|
43
|
+
* ⚠️ A reload is the obvious half and the smaller one. The shell UNMOUNTS the sidebar's content
|
|
44
|
+
* when the panel collapses, so plain component state here dies at the first click on the sidebar
|
|
45
|
+
* trigger — the tree came back closed from a gesture that says nothing about folders (#805).
|
|
46
|
+
*
|
|
47
|
+
* ⚠️ What is stored is a CLAIM about a gesture, not a fact about the tree: a folder that has since
|
|
48
|
+
* been archived, moved away or unshared is still in here. Judging that is the tree's job and it
|
|
49
|
+
* already does it — a level whose row it cannot draw is never loaded (#521), and the next gesture
|
|
50
|
+
* writes the trimmed list back. Trimming here as well would need this file to know what the tree
|
|
51
|
+
* shows, which is the one thing a storage adapter must not.
|
|
52
|
+
*/
|
|
53
|
+
export function createTreeExpansion(deps: TreeExpansionDeps = {}): TreeExpansionStore {
|
|
54
|
+
const storage = deps.storage === undefined ? browserStorage() : deps.storage;
|
|
55
|
+
|
|
56
|
+
return {
|
|
57
|
+
read() {
|
|
58
|
+
let raw: string | null = null;
|
|
59
|
+
try {
|
|
60
|
+
raw = storage?.getItem(storageKey) ?? null;
|
|
61
|
+
} catch {
|
|
62
|
+
return empty;
|
|
63
|
+
}
|
|
64
|
+
if (raw === null) return empty;
|
|
65
|
+
let parsed: unknown;
|
|
66
|
+
try {
|
|
67
|
+
parsed = JSON.parse(raw);
|
|
68
|
+
} catch {
|
|
69
|
+
return empty;
|
|
70
|
+
}
|
|
71
|
+
// ⚠️ A bare list is the shape #805 wrote, and a tab that was open across the two releases has
|
|
72
|
+
// one. It is read as levels without a revealed selection, which is exactly what it means.
|
|
73
|
+
const stored: unknown = Array.isArray(parsed) ? { levels: parsed, revealed: null } : parsed;
|
|
74
|
+
if (typeof stored !== "object" || stored === null) return empty;
|
|
75
|
+
const { levels: rawLevels, revealed } = stored as Partial<TreeExpansionState>;
|
|
76
|
+
if (!Array.isArray(rawLevels)) return empty;
|
|
77
|
+
const levels: ExpandedLevel[] = [];
|
|
78
|
+
for (const entry of rawLevels.slice(0, MaxRememberedLevels)) {
|
|
79
|
+
const level = levelFrom(entry);
|
|
80
|
+
if (level === null) continue;
|
|
81
|
+
// ⚠️ A bad entry is skipped rather than emptying the answer: one unreadable line says
|
|
82
|
+
// nothing about the others, and a tree that closes completely because of it is the very
|
|
83
|
+
// failure this file exists to remove. Duplicates go the same way — two identical entries
|
|
84
|
+
// would be two queries for one level.
|
|
85
|
+
if (levels.some((seen) => seen.id === level.id && seen.type === level.type)) continue;
|
|
86
|
+
levels.push(level);
|
|
87
|
+
}
|
|
88
|
+
return {
|
|
89
|
+
levels,
|
|
90
|
+
revealed: typeof revealed === "string" && revealed.length > 0 ? revealed : null,
|
|
91
|
+
};
|
|
92
|
+
},
|
|
93
|
+
write(state) {
|
|
94
|
+
try {
|
|
95
|
+
storage?.setItem(
|
|
96
|
+
storageKey,
|
|
97
|
+
JSON.stringify({
|
|
98
|
+
levels: state.levels
|
|
99
|
+
.slice(0, MaxRememberedLevels)
|
|
100
|
+
.map((level) => ({ id: level.id, type: level.type })),
|
|
101
|
+
revealed: state.revealed,
|
|
102
|
+
}),
|
|
103
|
+
);
|
|
104
|
+
} catch {}
|
|
105
|
+
},
|
|
106
|
+
};
|
|
107
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
// One remembered level of the tree. The root is deliberately NOT one of these: it has no row to
|
|
2
|
+
// open, it is always shown, and an entry for it could only ever be a value nothing reads.
|
|
3
|
+
export interface ExpandedLevel {
|
|
4
|
+
id: string;
|
|
5
|
+
type: "folder" | "flow";
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* What the tree knows about itself between two lives of the component.
|
|
10
|
+
*
|
|
11
|
+
* ⚠️ `revealed` travels WITH the levels rather than beside them, and that is the whole reason this
|
|
12
|
+
* is an object rather than a list (#809). It names the selection the tree has already opened itself
|
|
13
|
+
* onto. Kept only in memory it dies with the panel — and the panel dies every time the sidebar is
|
|
14
|
+
* collapsed — so a folder the reader closed by hand would be opened again by a reveal that has
|
|
15
|
+
* forgotten it already happened.
|
|
16
|
+
*/
|
|
17
|
+
export interface TreeExpansionState {
|
|
18
|
+
levels: readonly ExpandedLevel[];
|
|
19
|
+
revealed: string | null;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface TreeExpansionStore {
|
|
23
|
+
read(): TreeExpansionState;
|
|
24
|
+
write(state: TreeExpansionState): void;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface TreeExpansionDeps {
|
|
28
|
+
// A `null` storage disables persistence. `undefined` means "use the browser's", which is not the
|
|
29
|
+
// same thing: a test passes null, a browser without localStorage produces null on its own.
|
|
30
|
+
storage?: Pick<Storage, "getItem" | "setItem"> | null;
|
|
31
|
+
}
|
|
@@ -0,0 +1,427 @@
|
|
|
1
|
+
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
|
2
|
+
import { ChevronLeft, Folder } from "lucide-react";
|
|
3
|
+
import type * as React from "react";
|
|
4
|
+
import { useState } from "react";
|
|
5
|
+
import type { TreeEntry } from "@/data/intel-data-provider/intel-data-provider.types.ts";
|
|
6
|
+
import { useI18n } from "@/i18n/i18n-context.tsx";
|
|
7
|
+
import { Modal } from "@/modal/modal.tsx";
|
|
8
|
+
import { useIntelRouterContext } from "@/router/router-context.ts";
|
|
9
|
+
|
|
10
|
+
// Where a row would land. `null` is the root of the shared tree; the title is carried along because
|
|
11
|
+
// the dialog names the destination and the row that supplied it is not always still on screen.
|
|
12
|
+
export type MoveDestination = { id: string | null; title: string };
|
|
13
|
+
|
|
14
|
+
export type MoveVerdict = "ok" | "self" | "descendant" | "same-place";
|
|
15
|
+
|
|
16
|
+
// The parent a row is filed under. Nodes and Flows keep their own record (ADR-0004), so the
|
|
17
|
+
// answer is read from whichever one this row is, never from a merged shape.
|
|
18
|
+
//
|
|
19
|
+
// ⚠️ This is the parent the RECORD names, which since #429 is not always the level the row is shown
|
|
20
|
+
// in: a shared document is filed inside the sharer's folder and reaches the root only through the
|
|
21
|
+
// share. Everything about a move — which level loses the row, which one keeps it, and whether the
|
|
22
|
+
// root is "same place" — is a question about the level on screen, so the level travels with the
|
|
23
|
+
// move instead of being read back out of the record here (#446).
|
|
24
|
+
export function parentOf(entry: TreeEntry): string | null {
|
|
25
|
+
return entry.type === "node" ? entry.node.parentId : entry.flow.parentId;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// One level of the shared tree, as a query key. The move writes into two of them and the picker
|
|
29
|
+
// reads a third, so the shape is stated once rather than spelled out at each of those places.
|
|
30
|
+
export function treeLevelKey(parentId: string | null): readonly unknown[] {
|
|
31
|
+
return ["tree", parentId];
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Every level of the tree at once — what a write that changed what is IN a folder makes stale.
|
|
36
|
+
*
|
|
37
|
+
* ⚠️ Not the level that was written into: that is the obvious half, and it was the whole of #519.
|
|
38
|
+
* A folder ROW carries `openable` — since #59 the arrow only stands where there is something to
|
|
39
|
+
* open — and that is a statement about the folder's CONTENTS living one level ABOVE it. Refresh
|
|
40
|
+
* only the level below and the row keeps yesterday's answer: the first thing filed into an EMPTY
|
|
41
|
+
* folder has no arrow to appear under, `reveal` opens a level `renderRow` then never renders, and
|
|
42
|
+
* reloading the page was the only thing that read the row again. The other direction is the same
|
|
43
|
+
* bug from the other side — the last child moved or archived out leaves an arrow promising a level
|
|
44
|
+
* that is empty.
|
|
45
|
+
*
|
|
46
|
+
* ⚠️ A prefix rather than "the level, plus the level its folder row sits in", because that second
|
|
47
|
+
* level is not always the one the record names: since #429 a shared row also stands at the root
|
|
48
|
+
* (`levelsToClear`).
|
|
49
|
+
*
|
|
50
|
+
* ⚠️ What it costs is one request per level the reader has OPEN — bounded by the screen, not by how
|
|
51
|
+
* long the tab has been sitting there. `app-tree.tsx` loads a level only while the row it hangs on
|
|
52
|
+
* still stands in the level above it, so a folder that was opened and has since left the tree —
|
|
53
|
+
* archived, moved away, its share revoked — takes its query with it rather than being re-read by
|
|
54
|
+
* every write for the rest of the session.
|
|
55
|
+
*
|
|
56
|
+
* The same prefix is what `archive.tsx` has always used for the same reason: a row can come back
|
|
57
|
+
* anywhere, and the screen writing it does not know where.
|
|
58
|
+
*/
|
|
59
|
+
export const allTreeLevelsKey: readonly unknown[] = ["tree"];
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Which cached levels a move has to take the row out of.
|
|
63
|
+
*
|
|
64
|
+
* ⚠️ Normally one, and the caller names it. When it does not — the title line has no tree level to
|
|
65
|
+
* name — the answer is not a guess but the full set of places the row can be standing in, and that
|
|
66
|
+
* set has exactly two members.
|
|
67
|
+
*
|
|
68
|
+
* It is two rather than "all of them" because of how a level is read (`db.ts`): every level except
|
|
69
|
+
* the root is strictly `parent_id IS ?`, so there the level and the record always agree. Only the
|
|
70
|
+
* root also collects the top of every readable region — a shared row whose own record names the
|
|
71
|
+
* sharer's folder (#429). So a row is either in the level its record names, or in the root.
|
|
72
|
+
*
|
|
73
|
+
* Cleaning up one level too many costs a re-read of a list the reader is looking at anyway.
|
|
74
|
+
* Cleaning up one too few is #446: the row stays where it no longer is.
|
|
75
|
+
*/
|
|
76
|
+
export function levelsToClear(
|
|
77
|
+
entry: TreeEntry,
|
|
78
|
+
level: string | null | undefined,
|
|
79
|
+
): (string | null)[] {
|
|
80
|
+
if (level !== undefined) return [level];
|
|
81
|
+
const recorded = parentOf(entry);
|
|
82
|
+
return recorded === null ? [null] : [recorded, null];
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// The optimistic row, filed where it is about to land. Its own parent has to travel with it, or the
|
|
86
|
+
// plus on the moved row would still file into the folder it just left.
|
|
87
|
+
function withParent(entry: TreeEntry, parentId: string | null): TreeEntry {
|
|
88
|
+
return entry.type === "node"
|
|
89
|
+
? { ...entry, node: { ...entry.node, parentId } }
|
|
90
|
+
: { ...entry, flow: { ...entry.flow, parentId } };
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* ⚠️ The trap this ticket is built around: a node must not travel into its own descendants. The
|
|
95
|
+
* service refuses it (`move_cycle`, `nodes.ts:421`), but a refusal that only arrives once the
|
|
96
|
+
* mouse button is already up is the one answer worse than no dragging at all — the drop has to be
|
|
97
|
+
* offered or withheld while the pointer is still moving.
|
|
98
|
+
*
|
|
99
|
+
* It costs no request. The tree already carries the ancestor path of every rendered row for its own
|
|
100
|
+
* cycle guard (`app-tree.tsx`), and "is this target inside the row I am dragging" is exactly the
|
|
101
|
+
* question that path answers: the dragged id appears above the target, or it does not.
|
|
102
|
+
*/
|
|
103
|
+
export function moveVerdict(input: {
|
|
104
|
+
draggedId: string;
|
|
105
|
+
// ⚠️ Two answers to "where is this row", and since #429 they can differ — which is why both are
|
|
106
|
+
// asked (#446). `draggedParentId` is what the record says and what a move would overwrite;
|
|
107
|
+
// `draggedLevel` is the level the row is shown in.
|
|
108
|
+
//
|
|
109
|
+
// "Same place" has to be true for either, and for different reasons:
|
|
110
|
+
//
|
|
111
|
+
// - The record: dropping a row on the folder its own record already names writes the value that
|
|
112
|
+
// is already there. Nothing happens, and offering it says something will.
|
|
113
|
+
// - The level: a shared row reaches the root only through the share, and its record names the
|
|
114
|
+
// sharer's folder. Dropping it on the root looks like a no-op to the reader — but it would
|
|
115
|
+
// write `parentId = null` and lift the document OUT of the folder it was shared from. That is
|
|
116
|
+
// the opposite of nothing, done by somebody who was told nothing would happen.
|
|
117
|
+
//
|
|
118
|
+
// `draggedLevel` is optional because a surface without a tree has no level to name — the resource
|
|
119
|
+
// menu opens the same dialog from a title line. There the record is the only answer there is.
|
|
120
|
+
draggedParentId: string | null;
|
|
121
|
+
draggedLevel?: string | null | undefined;
|
|
122
|
+
targetId: string | null;
|
|
123
|
+
targetAncestors: ReadonlySet<string>;
|
|
124
|
+
}): MoveVerdict {
|
|
125
|
+
if (input.targetId === input.draggedId) return "self";
|
|
126
|
+
if (input.targetId !== null && input.targetAncestors.has(input.draggedId)) return "descendant";
|
|
127
|
+
if (input.targetId === input.draggedParentId) return "same-place";
|
|
128
|
+
if (input.draggedLevel !== undefined && input.targetId === input.draggedLevel) {
|
|
129
|
+
return "same-place";
|
|
130
|
+
}
|
|
131
|
+
return "ok";
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// ⚠️ Five refusals, five sentences. The service tells them apart by `code`
|
|
135
|
+
// (`nodes.ts:331/335/338/361`, `flows.ts:205–217/752`), and so does this: one message for all
|
|
136
|
+
// of them would leave the reader guessing which of "you may not write there", "that is not a
|
|
137
|
+
// folder", "that would be a loop", "that destination is archived" and "somebody else was faster"
|
|
138
|
+
// they have just hit.
|
|
139
|
+
//
|
|
140
|
+
// ⚠️ `parent_archived` reaches this since #679: before that the service raised it for tasks only,
|
|
141
|
+
// which never travel through the tree's move. The board is not a drop target for an archived node,
|
|
142
|
+
// so a person meets it rarely — but a move over MCP or HTTP hits it, and the tree reloads from the
|
|
143
|
+
// same error.
|
|
144
|
+
export function moveErrorKey(error: unknown): string {
|
|
145
|
+
const code =
|
|
146
|
+
typeof error === "object" && error !== null && "code" in error
|
|
147
|
+
? String((error as { code: unknown }).code)
|
|
148
|
+
: null;
|
|
149
|
+
switch (code) {
|
|
150
|
+
case "node_forbidden":
|
|
151
|
+
case "flow_edit_forbidden":
|
|
152
|
+
return "tree.move.failed.forbidden";
|
|
153
|
+
case "parent_not_folder":
|
|
154
|
+
return "tree.move.failed.notFolder";
|
|
155
|
+
case "parent_archived":
|
|
156
|
+
return "tree.move.failed.archived";
|
|
157
|
+
case "move_cycle":
|
|
158
|
+
return "tree.move.failed.cycle";
|
|
159
|
+
case "update_conflict":
|
|
160
|
+
case "flow_update_conflict":
|
|
161
|
+
return "tree.move.failed.conflict";
|
|
162
|
+
case "flow_parent_not_found":
|
|
163
|
+
return "tree.move.failed.gone";
|
|
164
|
+
default:
|
|
165
|
+
return "tree.move.failed.other";
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* The move, named before it happens.
|
|
171
|
+
*
|
|
172
|
+
* ⚠️ Since #16 a share hangs on the folder and reaches everything beneath it (`db-grants.ts`,
|
|
173
|
+
* `nodeVerbQuery`), so moving a document is a change of who may read it — without the word "share"
|
|
174
|
+
* appearing anywhere. That is why every move is confirmed rather than performed on drop.
|
|
175
|
+
*
|
|
176
|
+
* What this deliberately does not do is name the people. `listGrants` answers with the
|
|
177
|
+
* grants sitting on that one folder, needs `share` on it (`nodes.ts:459`), and says nothing
|
|
178
|
+
* about the grants above it that reach down. A list built from that would be short of exactly the
|
|
179
|
+
* entries that matter and would read as complete — "nobody is on this folder" while an ancestor
|
|
180
|
+
* opens it to the whole organization. Naming the mechanism truthfully beats enumerating it wrongly;
|
|
181
|
+
* an effective-audience answer is the server's to give, and no endpoint offers one today.
|
|
182
|
+
*/
|
|
183
|
+
export function MoveDialog({
|
|
184
|
+
entry,
|
|
185
|
+
level,
|
|
186
|
+
close,
|
|
187
|
+
submit,
|
|
188
|
+
}: {
|
|
189
|
+
entry: TreeEntry;
|
|
190
|
+
// `undefined` where no tree level was rendered — the verdict then rests on the record alone.
|
|
191
|
+
level: string | null | undefined;
|
|
192
|
+
close(): void;
|
|
193
|
+
submit(destination: MoveDestination): void;
|
|
194
|
+
}) {
|
|
195
|
+
const { data } = useIntelRouterContext();
|
|
196
|
+
const i18n = useI18n();
|
|
197
|
+
// The browsed path, root first. It is also the descendant guard for the keyboard route: the row
|
|
198
|
+
// being moved is never offered, so its subtree can never be entered in the first place.
|
|
199
|
+
const [path, setPath] = useState<MoveDestination[]>([]);
|
|
200
|
+
const here: MoveDestination = path.at(-1) ?? { id: null, title: i18n.t("tree.move.root") };
|
|
201
|
+
const children = useQuery({
|
|
202
|
+
queryKey: ["tree", here.id],
|
|
203
|
+
queryFn: async () => await data.listTreeChildren(here.id),
|
|
204
|
+
});
|
|
205
|
+
const folders = (children.data ?? []).filter(
|
|
206
|
+
(child) => child.kind === "folder" && child.id !== entry.id,
|
|
207
|
+
);
|
|
208
|
+
const verdict = moveVerdict({
|
|
209
|
+
draggedId: entry.id,
|
|
210
|
+
draggedParentId: parentOf(entry),
|
|
211
|
+
// Handed down by the caller, because the dialog is opened from two places and only one of them
|
|
212
|
+
// has a tree level to name (#446).
|
|
213
|
+
draggedLevel: level,
|
|
214
|
+
targetId: here.id,
|
|
215
|
+
targetAncestors: new Set(path.map((step) => step.id).filter((id) => id !== null)),
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
return (
|
|
219
|
+
<Modal title={i18n.t("tree.move.title", { title: entry.title })} close={close}>
|
|
220
|
+
{
|
|
221
|
+
<div className="space-y-3">
|
|
222
|
+
<p className="text-sm text-muted-foreground">{i18n.t("tree.move.pick")}</p>
|
|
223
|
+
<div className="flex items-center gap-2">
|
|
224
|
+
<button
|
|
225
|
+
type="button"
|
|
226
|
+
disabled={path.length === 0}
|
|
227
|
+
onClick={() => setPath((current) => current.slice(0, -1))}
|
|
228
|
+
aria-label={i18n.t("tree.move.up")}
|
|
229
|
+
className="rounded-md border p-1 outline-none hover:bg-muted focus-visible:ring-2 focus-visible:ring-ring disabled:opacity-50"
|
|
230
|
+
>
|
|
231
|
+
<ChevronLeft aria-hidden="true" className="size-4" />
|
|
232
|
+
</button>
|
|
233
|
+
<p className="min-w-0 flex-1 truncate text-sm font-medium">{here.title}</p>
|
|
234
|
+
</div>
|
|
235
|
+
<ul className="max-h-56 space-y-1 overflow-y-auto">
|
|
236
|
+
{children.isPending ? (
|
|
237
|
+
<li className="px-2 py-1.5 text-sm text-muted-foreground">
|
|
238
|
+
{i18n.t("common.loading")}
|
|
239
|
+
</li>
|
|
240
|
+
) : folders.length === 0 ? (
|
|
241
|
+
<li className="px-2 py-1.5 text-sm text-muted-foreground">
|
|
242
|
+
{i18n.t("tree.move.noFolders")}
|
|
243
|
+
</li>
|
|
244
|
+
) : (
|
|
245
|
+
folders.map((folder) => (
|
|
246
|
+
<li key={folder.id}>
|
|
247
|
+
<button
|
|
248
|
+
type="button"
|
|
249
|
+
onClick={() => setPath((current) => [...current, folder])}
|
|
250
|
+
aria-label={i18n.t("tree.move.open", { title: folder.title })}
|
|
251
|
+
className="flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left text-sm outline-none hover:bg-muted focus-visible:ring-2 focus-visible:ring-ring"
|
|
252
|
+
>
|
|
253
|
+
<Folder aria-hidden="true" className="size-4 shrink-0" />
|
|
254
|
+
<span className="truncate">{folder.title}</span>
|
|
255
|
+
</button>
|
|
256
|
+
</li>
|
|
257
|
+
))
|
|
258
|
+
)}
|
|
259
|
+
</ul>
|
|
260
|
+
{/* ⚠️ This IS the move, not a choice that opens a confirmation. Until #776 it set a
|
|
261
|
+
destination and a second screen asked again, carrying a sentence about inherited
|
|
262
|
+
sharing; Jack removed both on 2026-08-24 — inheritance is what a person expects of a
|
|
263
|
+
folder tree, and a warning that says the same self-evident thing on every move is one
|
|
264
|
+
nobody reads by the third time. The refusals are untouched: `moveErrorKey` still words
|
|
265
|
+
all six, and a silent failure would be worse than the dialog ever was. */}
|
|
266
|
+
<button
|
|
267
|
+
type="button"
|
|
268
|
+
disabled={verdict !== "ok"}
|
|
269
|
+
onClick={() => submit(here)}
|
|
270
|
+
className="w-full rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground outline-none hover:bg-primary/90 focus-visible:ring-2 focus-visible:ring-ring disabled:opacity-50"
|
|
271
|
+
>
|
|
272
|
+
{i18n.t("tree.move.here", { title: here.title })}
|
|
273
|
+
</button>
|
|
274
|
+
{verdict === "same-place" ? (
|
|
275
|
+
<p className="text-sm text-muted-foreground">{i18n.t("tree.move.alreadyThere")}</p>
|
|
276
|
+
) : null}
|
|
277
|
+
</div>
|
|
278
|
+
}
|
|
279
|
+
</Modal>
|
|
280
|
+
);
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/**
|
|
284
|
+
* Moving a row, wherever it is asked for.
|
|
285
|
+
*
|
|
286
|
+
* ⚠️ It is asked for from two places now (#58): a drop on a folder in the tree, and the menu in the
|
|
287
|
+
* title line — the keyboard route, which since #58 is the only one the menu still has anywhere. Both
|
|
288
|
+
* are the same move, so both take it from here rather than each carrying its own mutation. Two
|
|
289
|
+
* copies of an optimistic update that rewrites two cached levels is how a rejected move ends up
|
|
290
|
+
* leaving a row standing twice.
|
|
291
|
+
*
|
|
292
|
+
* ⚠️ Optimistic, never authoritative. The row is lifted out of one level and dropped into the other
|
|
293
|
+
* before the server answers, and every refusal — 403, `parent_not_folder`, `move_cycle`,
|
|
294
|
+
* `update_conflict` — puts both levels back exactly as they were and then re-reads them.
|
|
295
|
+
*
|
|
296
|
+
* The caller renders `dialog` where it likes and words `error` itself with `moveErrorKey`: the tree
|
|
297
|
+
* says it in the sidebar, the menu over the screen, and neither position belongs to the move.
|
|
298
|
+
*/
|
|
299
|
+
export function useTreeMove({
|
|
300
|
+
onMoved,
|
|
301
|
+
}: {
|
|
302
|
+
// What the caller wants to do with the destination once the move is through — the tree opens that
|
|
303
|
+
// folder so the row is where the eye follows it. Nobody else has a tree to open.
|
|
304
|
+
onMoved?: ((destination: MoveDestination) => void) | undefined;
|
|
305
|
+
} = {}): {
|
|
306
|
+
// `level` is the one the row is standing in, and the caller says it or says `undefined` — the tree
|
|
307
|
+
// knows it from the level it rendered into, the title line has no tree at all. Guessing it here
|
|
308
|
+
// with `parentOf` is what filed a moved row into a cache level that does not exist (#446).
|
|
309
|
+
//
|
|
310
|
+
// ⚠️ `undefined` is not "root". It means the level is unknown, and it is handled by cleaning up
|
|
311
|
+
// BOTH levels such a row can stand in — see `levelsToClear`.
|
|
312
|
+
start(
|
|
313
|
+
entry: TreeEntry,
|
|
314
|
+
level: string | null | undefined,
|
|
315
|
+
destination: MoveDestination | null,
|
|
316
|
+
): void;
|
|
317
|
+
error: unknown;
|
|
318
|
+
dialog: React.ReactNode;
|
|
319
|
+
} {
|
|
320
|
+
const { data } = useIntelRouterContext();
|
|
321
|
+
const queryClient = useQueryClient();
|
|
322
|
+
const [moving, setMoving] = useState<{
|
|
323
|
+
entry: TreeEntry;
|
|
324
|
+
level: string | null | undefined;
|
|
325
|
+
initial: MoveDestination | null;
|
|
326
|
+
} | null>(null);
|
|
327
|
+
|
|
328
|
+
const move = useMutation({
|
|
329
|
+
mutationFn: async ({
|
|
330
|
+
entry,
|
|
331
|
+
destination,
|
|
332
|
+
}: {
|
|
333
|
+
entry: TreeEntry;
|
|
334
|
+
level: string | null | undefined;
|
|
335
|
+
destination: MoveDestination;
|
|
336
|
+
}) => {
|
|
337
|
+
// `baseUpdatedAt` travels with the move: it is what turns a concurrent edit into a 409 the
|
|
338
|
+
// view can act on instead of an overwrite nobody notices.
|
|
339
|
+
if (entry.type === "flow") {
|
|
340
|
+
await data.updateFlow({
|
|
341
|
+
flowId: entry.id,
|
|
342
|
+
baseUpdatedAt: entry.flow.updatedAt,
|
|
343
|
+
parentId: destination.id,
|
|
344
|
+
idempotencyKey: crypto.randomUUID(),
|
|
345
|
+
});
|
|
346
|
+
} else {
|
|
347
|
+
await data.updateNode({
|
|
348
|
+
nodeId: entry.id,
|
|
349
|
+
baseUpdatedAt: entry.node.updatedAt,
|
|
350
|
+
parentId: destination.id,
|
|
351
|
+
idempotencyKey: crypto.randomUUID(),
|
|
352
|
+
});
|
|
353
|
+
}
|
|
354
|
+
},
|
|
355
|
+
onMutate: async ({ entry, level, destination }) => {
|
|
356
|
+
const fromKeys = levelsToClear(entry, level).map(treeLevelKey);
|
|
357
|
+
const toKey = treeLevelKey(destination.id);
|
|
358
|
+
await Promise.all(
|
|
359
|
+
[...fromKeys, toKey].map(async (queryKey) => await queryClient.cancelQueries({ queryKey })),
|
|
360
|
+
);
|
|
361
|
+
const snapshot = [
|
|
362
|
+
...fromKeys.map((key) => [key, queryClient.getQueryData<TreeEntry[]>(key)] as const),
|
|
363
|
+
[toKey, queryClient.getQueryData<TreeEntry[]>(toKey)] as const,
|
|
364
|
+
];
|
|
365
|
+
for (const key of fromKeys) {
|
|
366
|
+
queryClient.setQueryData<TreeEntry[]>(key, (current) =>
|
|
367
|
+
current?.filter((row) => row.id !== entry.id),
|
|
368
|
+
);
|
|
369
|
+
}
|
|
370
|
+
// A level nobody has opened stays unloaded: writing one here would show a folder's contents
|
|
371
|
+
// that were never read.
|
|
372
|
+
queryClient.setQueryData<TreeEntry[]>(toKey, (current) =>
|
|
373
|
+
current === undefined
|
|
374
|
+
? current
|
|
375
|
+
: [...current.filter((row) => row.id !== entry.id), withParent(entry, destination.id)],
|
|
376
|
+
);
|
|
377
|
+
return { snapshot };
|
|
378
|
+
},
|
|
379
|
+
onError: (_error, _variables, context) => {
|
|
380
|
+
for (const [key, value] of context?.snapshot ?? []) queryClient.setQueryData(key, value);
|
|
381
|
+
},
|
|
382
|
+
onSuccess: (_result, { destination }) => onMoved?.(destination),
|
|
383
|
+
onSettled: async (_result, _error, { entry }) => {
|
|
384
|
+
await Promise.all([
|
|
385
|
+
// Both ends of the move and the rows they hang under: a folder that just received its first
|
|
386
|
+
// row needs its arrow, and one that lost its last has to give it back (#519).
|
|
387
|
+
queryClient.invalidateQueries({ queryKey: allTreeLevelsKey }),
|
|
388
|
+
queryClient.invalidateQueries({
|
|
389
|
+
queryKey: [entry.type === "flow" ? "flows" : "node-graph"],
|
|
390
|
+
}),
|
|
391
|
+
queryClient.invalidateQueries({ queryKey: ["relation-graph"] }),
|
|
392
|
+
]);
|
|
393
|
+
},
|
|
394
|
+
});
|
|
395
|
+
|
|
396
|
+
return {
|
|
397
|
+
/**
|
|
398
|
+
* ⚠️ A destination that is already known writes straight away — a drop names its folder, and
|
|
399
|
+
* asking again about the folder the pointer just landed on is a question with one answer
|
|
400
|
+
* (#776). Only the route that has NO destination opens the picker: the menu, which is the
|
|
401
|
+
* keyboard path.
|
|
402
|
+
*
|
|
403
|
+
* The optimistic update in `onMutate` is what makes this safe to do without a screen in
|
|
404
|
+
* between: the row moves at once and every refusal puts both levels back.
|
|
405
|
+
*/
|
|
406
|
+
start(entry, level, destination) {
|
|
407
|
+
move.reset();
|
|
408
|
+
if (destination !== null) {
|
|
409
|
+
move.mutate({ entry, level, destination });
|
|
410
|
+
return;
|
|
411
|
+
}
|
|
412
|
+
setMoving({ entry, level, initial: null });
|
|
413
|
+
},
|
|
414
|
+
error: move.isError ? move.error : null,
|
|
415
|
+
dialog: moving ? (
|
|
416
|
+
<MoveDialog
|
|
417
|
+
entry={moving.entry}
|
|
418
|
+
level={moving.level}
|
|
419
|
+
close={() => setMoving(null)}
|
|
420
|
+
submit={(destination) => {
|
|
421
|
+
setMoving(null);
|
|
422
|
+
move.mutate({ entry: moving.entry, level: moving.level, destination });
|
|
423
|
+
}}
|
|
424
|
+
/>
|
|
425
|
+
) : null,
|
|
426
|
+
};
|
|
427
|
+
}
|