@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,1175 @@
|
|
|
1
|
+
import type { NodeKind } from "@contract/node.ts";
|
|
2
|
+
import { useMutation, useQueries, useQueryClient } from "@tanstack/react-query";
|
|
3
|
+
import { useNavigate, useRouterState } from "@tanstack/react-router";
|
|
4
|
+
import { ChevronRight, CornerLeftUp, FolderOpen, Plus, Upload } from "lucide-react";
|
|
5
|
+
import { useEffect, useId, useRef, useState } from "react";
|
|
6
|
+
import { createTreeExpansion } from "@/app/tree-expansion/tree-expansion.ts";
|
|
7
|
+
import type { ExpandedLevel } from "@/app/tree-expansion/tree-expansion.types.ts";
|
|
8
|
+
import {
|
|
9
|
+
allTreeLevelsKey,
|
|
10
|
+
type MoveDestination,
|
|
11
|
+
moveErrorKey,
|
|
12
|
+
moveVerdict,
|
|
13
|
+
parentOf,
|
|
14
|
+
treeLevelKey,
|
|
15
|
+
useTreeMove,
|
|
16
|
+
} from "@/app/tree-move/tree-move.tsx";
|
|
17
|
+
import { folderTrail } from "@/app/tree-reveal/tree-reveal.ts";
|
|
18
|
+
import type { RevealArea } from "@/app/tree-reveal/tree-reveal.types.ts";
|
|
19
|
+
import {
|
|
20
|
+
DropdownMenu,
|
|
21
|
+
DropdownMenuContent,
|
|
22
|
+
DropdownMenuItem,
|
|
23
|
+
DropdownMenuSeparator,
|
|
24
|
+
DropdownMenuTrigger,
|
|
25
|
+
} from "@/components/ui/dropdown-menu";
|
|
26
|
+
import {
|
|
27
|
+
SidebarGroup,
|
|
28
|
+
SidebarMenu,
|
|
29
|
+
SidebarMenuButton,
|
|
30
|
+
SidebarMenuItem,
|
|
31
|
+
SidebarMenuSkeleton,
|
|
32
|
+
} from "@/components/ui/sidebar";
|
|
33
|
+
import { flowEntry } from "@/data/intel-data-provider/intel-data-provider.ts";
|
|
34
|
+
import type { TreeEntry } from "@/data/intel-data-provider/intel-data-provider.types.ts";
|
|
35
|
+
import { refusalOf } from "@/data/request-refusal/request-refusal.ts";
|
|
36
|
+
import { folderContentsOf } from "@/folder-index/folder-index.ts";
|
|
37
|
+
import { useI18n } from "@/i18n/i18n-context.tsx";
|
|
38
|
+
import { iconFor, kindIcons } from "@/kind-icon.ts";
|
|
39
|
+
import { Modal } from "@/modal/modal.tsx";
|
|
40
|
+
import { useNodeImport } from "@/node-import/node-import.tsx";
|
|
41
|
+
import { useResourceRename } from "@/rename-resource/rename-resource.ts";
|
|
42
|
+
import { resourceErrorKey } from "@/resource-error.ts";
|
|
43
|
+
import { useIntelRouterContext } from "@/router/router-context.ts";
|
|
44
|
+
import { selectedFrom } from "@/router/selection-search.ts";
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* What the plus can MAKE — derived from `NodeKind`, never written out again (#395).
|
|
48
|
+
*
|
|
49
|
+
* ⚠️ Three decisions about this MENU, each excluded by name: `attachment` because an attachment is
|
|
50
|
+
* uploaded rather than created, `task` because a task lives on a board and the API refuses one
|
|
51
|
+
* filed under a folder, and `flow` added because a flow shares the tree without being a node
|
|
52
|
+
* (ADR-0004 §1). The LIST of node kinds is not a decision here, and a copy of it would go stale in
|
|
53
|
+
* the direction that already bit us — the parked kinds stood in such a union long after nothing
|
|
54
|
+
* could make one.
|
|
55
|
+
*
|
|
56
|
+
* ⚠️ `task` was NOT excluded until #661, and the wide form carried it in silence from #647 onward:
|
|
57
|
+
* a menu entry that could only ever answer with a refusal. It is a type error now.
|
|
58
|
+
*/
|
|
59
|
+
type NewKind = Exclude<NodeKind, "attachment" | "task"> | "flow";
|
|
60
|
+
type Creating = { parentId: string | null; kind: NewKind };
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* A byte count as the sentence beside it says it (#821).
|
|
64
|
+
*
|
|
65
|
+
* ⚠️ Megabytes of a million, because that is what the limit IS: the constants are round decimal
|
|
66
|
+
* numbers, and dividing them by 1024 would put "47.7 MB" in front of somebody whose file manager
|
|
67
|
+
* says the same file is 48 MB. The number in the message and the number in the check have to be
|
|
68
|
+
* the same number.
|
|
69
|
+
*/
|
|
70
|
+
// A level is either a folder of the shared tree or the flows one flow calls. The second kind is
|
|
71
|
+
// derived from that flow's graph, not from `parent_id` (ADR-0004 §3), which is why it is its own
|
|
72
|
+
// level rather than another folder.
|
|
73
|
+
type Level = { id: string | null; type: ExpandedLevel["type"] };
|
|
74
|
+
|
|
75
|
+
// One key per level. `null` is the root; every expanded row adds one of its own, and nothing else is
|
|
76
|
+
// ever asked for.
|
|
77
|
+
function levelKey(level: Level): readonly unknown[] {
|
|
78
|
+
return level.type === "flow" ? ["flow-calls", level.id] : treeLevelKey(level.id);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// What a drag carries: the row, and the LEVEL it was picked up from.
|
|
82
|
+
//
|
|
83
|
+
// ⚠️ The two are not the same, and that is the whole of #446. Since #429 the root level also shows
|
|
84
|
+
// rows whose own record names a folder this reader may never see — a shared document reaches the
|
|
85
|
+
// root only through the share. Asking `parentOf` again during the move answers with that invisible
|
|
86
|
+
// folder, and then the root offers "move to the top" to a row already at the top, while the level
|
|
87
|
+
// it actually left is never cleaned up. The level is known where the row is rendered; carrying it
|
|
88
|
+
// is cheaper than deriving it wrongly.
|
|
89
|
+
type Carried = { entry: TreeEntry; level: string | null };
|
|
90
|
+
|
|
91
|
+
// What a row puts on the clipboard for anybody outside the tree. Its own media type rather than
|
|
92
|
+
// `text/plain`, so a drop target that means "move this row" and one that means "make a node for
|
|
93
|
+
// this" cannot be confused by the same payload.
|
|
94
|
+
export const TreeEntryMediaType = "application/x-intel-tree-entry";
|
|
95
|
+
|
|
96
|
+
export function AppTree() {
|
|
97
|
+
const { data } = useIntelRouterContext();
|
|
98
|
+
const i18n = useI18n();
|
|
99
|
+
const queryClient = useQueryClient();
|
|
100
|
+
const navigate = useNavigate();
|
|
101
|
+
// ⚠️ Read once and written through, the same shape `sidebar-preferences` uses for the shell's own
|
|
102
|
+
// two values — and here for two reasons rather than one. A reload starts from `[]`, which is the
|
|
103
|
+
// obvious half; the smaller-looking one is that the shell UNMOUNTS this panel's content when the
|
|
104
|
+
// sidebar collapses (`app-sidebar.tsx`, #29), so component state alone dies at the first click on
|
|
105
|
+
// the sidebar trigger and the tree comes back closed from a gesture that says nothing about
|
|
106
|
+
// folders (#805).
|
|
107
|
+
const expansion = useRef(createTreeExpansion()).current;
|
|
108
|
+
const stored = useRef(expansion.read()).current;
|
|
109
|
+
const [expanded, setExpanded] = useState<readonly ExpandedLevel[]>(stored.levels);
|
|
110
|
+
// ⚠️ The write is an effect rather than a line in each mutator, and that is a decision taken when
|
|
111
|
+
// the SECOND writer arrived (#809): the reveal below sets `expanded` from inside a promise, where
|
|
112
|
+
// the state of this render is no longer the newest one. Every writer therefore uses the updater
|
|
113
|
+
// form and nobody writes storage by hand — one place, and it cannot be forgotten by the third.
|
|
114
|
+
const revealedFor = useRef<string | null>(stored.revealed);
|
|
115
|
+
useEffect(() => {
|
|
116
|
+
expansion.write({ levels: expanded, revealed: revealedFor.current });
|
|
117
|
+
}, [expanded, expansion]);
|
|
118
|
+
const [creating, setCreating] = useState<Creating | null>(null);
|
|
119
|
+
// Which row is being renamed in place, if any (#823). The ENTRY rather than its id: the mutation
|
|
120
|
+
// needs `updatedAt` from the row that was read, and looking it up again from the level would read
|
|
121
|
+
// a row that may have moved on — which is the one thing `baseUpdatedAt` exists to catch.
|
|
122
|
+
const [renaming, setRenaming] = useState<TreeEntry | null>(null);
|
|
123
|
+
const [dragged, setDragged] = useState<Carried | null>(null);
|
|
124
|
+
const draggedRef = useRef<Carried | null>(null);
|
|
125
|
+
// The id of the one sentence every movable row is described by (#590). Minted rather than written
|
|
126
|
+
// down: two trees on one page would otherwise share a literal id, and the second would describe
|
|
127
|
+
// its rows with the first one's element.
|
|
128
|
+
const movableId = useId();
|
|
129
|
+
// Which target the pointer is over: `undefined` for none, `null` for the root strip, an id for a
|
|
130
|
+
// folder row. Three answers, because "the root" and "nothing" are not the same drop.
|
|
131
|
+
const [over, setOver] = useState<string | null | undefined>(undefined);
|
|
132
|
+
// The drop's half of the move. The other half is the folder picker in the title line's menu, and
|
|
133
|
+
// both go through the same mutation (#58, `useTreeMove`) — the tree only adds what a tree can add,
|
|
134
|
+
// which is opening the folder the row just landed in.
|
|
135
|
+
const move = useTreeMove({
|
|
136
|
+
onMoved: (destination) => {
|
|
137
|
+
if (destination.id !== null) toggle({ id: destination.id, type: "folder" }, true);
|
|
138
|
+
},
|
|
139
|
+
});
|
|
140
|
+
/**
|
|
141
|
+
* One window for everything that comes in from the machine (#822).
|
|
142
|
+
*
|
|
143
|
+
* ⚠️ Until #822 this held the ZIP and the folder, and a single file had an entry of its own
|
|
144
|
+
* beside them; a folder's three-dot menu had the first two and not the third, so a file could be
|
|
145
|
+
* brought to the top level and not into a folder. All three answer the same question, so they sit
|
|
146
|
+
* behind the same word — and the gap closed without anybody having to notice it was there.
|
|
147
|
+
*/
|
|
148
|
+
const nodeImport = useNodeImport({
|
|
149
|
+
where: i18n.t("tree.move.root"),
|
|
150
|
+
onImported: (parentId) => {
|
|
151
|
+
if (parentId !== null) toggle({ id: parentId, type: "folder" }, true);
|
|
152
|
+
},
|
|
153
|
+
onUploaded: async (result) => {
|
|
154
|
+
const only = result.created.length === 1 ? result.created[0] : undefined;
|
|
155
|
+
// ⚠️ One file lands where it was made; several do not. A run of ten cannot navigate to one of
|
|
156
|
+
// them without choosing for the reader — and the choice would be arbitrary, because the tree
|
|
157
|
+
// shows all ten anyway. What both cases share is the reveal, which is what puts them there.
|
|
158
|
+
try {
|
|
159
|
+
if (only !== undefined) await navigate({ to: "/nodes", search: { select: only } });
|
|
160
|
+
} finally {
|
|
161
|
+
await reveal(result.parentId, "node-graph");
|
|
162
|
+
}
|
|
163
|
+
},
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
const location = useRouterState({
|
|
167
|
+
select: (state) => ({
|
|
168
|
+
pathname: state.location.pathname,
|
|
169
|
+
select: selectedFrom(state.location.search),
|
|
170
|
+
}),
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
// ⚠️ The whole point of #11: one query per level, and a level only exists while its folder is
|
|
174
|
+
// open. A recursive load would put an N+1 on every page of the app, because the tree is now on
|
|
175
|
+
// every page.
|
|
176
|
+
// An expanded flow shows what it calls — with the same arrow promise one level deeper: a called
|
|
177
|
+
// flow that calls flows of its own can be expanded further (#59).
|
|
178
|
+
async function flowCallRows(flowId: string): Promise<TreeEntry[]> {
|
|
179
|
+
const calls = await data.listFlowCalls(flowId);
|
|
180
|
+
const openable = new Set(calls.withCalls);
|
|
181
|
+
return calls.items.map((flow) => flowEntry(flow, openable.has(flow.id)));
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* The levels that still have a row to hang on — `expanded` minus everything the tree no longer
|
|
186
|
+
* shows (#521).
|
|
187
|
+
*
|
|
188
|
+
* ⚠️ `expanded` is only ever added to. A folder that was opened and has since left the tree —
|
|
189
|
+
* archived, moved away, its share revoked — used to keep its level in `useQueries` for the rest
|
|
190
|
+
* of the session, and every write from then on re-read a list nothing draws. Nothing was wrong on
|
|
191
|
+
* screen (`isOpen` needs the row, so the level was never rendered); the requests were simply paid
|
|
192
|
+
* for, and the number grew with the length of the SESSION rather than with what is on screen.
|
|
193
|
+
*
|
|
194
|
+
* ⚠️ Read from the cache rather than from `levels` below, and that is not a shortcut: the answer
|
|
195
|
+
* is needed BEFORE `useQueries` is told what to load, and deriving it from the results afterwards
|
|
196
|
+
* can only correct the list one render too late — by which time the request has been made.
|
|
197
|
+
*
|
|
198
|
+
* ⚠️ A walk down from the root, not "is the row anywhere": a level whose own row is gone must not
|
|
199
|
+
* keep vouching for the levels under it, or a whole opened branch would survive its top.
|
|
200
|
+
*
|
|
201
|
+
* ⚠️ A row is enough, and `openable` is deliberately NOT part of it although `isOpen` checks it.
|
|
202
|
+
* `reveal` opens a folder BEFORE the invalidation that gives it its arrow, so a folder that was
|
|
203
|
+
* empty a moment ago still says `openable: false` at that instant — judging on it would close the
|
|
204
|
+
* folder #519 exists to have opened. A refetch does not make a level unknown either: TanStack
|
|
205
|
+
* keeps the previous data while it refetches, so an ordinary invalidation closes nothing.
|
|
206
|
+
*/
|
|
207
|
+
const rowLevel = (entry: TreeEntry) =>
|
|
208
|
+
`${entry.kind === "folder" ? "folder" : "flow"}:${entry.id}`;
|
|
209
|
+
const rootLevel: Level = { id: null, type: "folder" };
|
|
210
|
+
const open: Level[] = [];
|
|
211
|
+
let frontier: Level[] = [rootLevel];
|
|
212
|
+
let waiting = [...expanded];
|
|
213
|
+
while (frontier.length > 0 && waiting.length > 0) {
|
|
214
|
+
const shown = new Set(
|
|
215
|
+
frontier.flatMap((level) =>
|
|
216
|
+
(queryClient.getQueryData<TreeEntry[]>(levelKey(level)) ?? []).map(rowLevel),
|
|
217
|
+
),
|
|
218
|
+
);
|
|
219
|
+
frontier = waiting.filter((level) => shown.has(`${level.type}:${level.id}`));
|
|
220
|
+
waiting = waiting.filter((level) => !frontier.includes(level));
|
|
221
|
+
open.push(...frontier);
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
const parents: Level[] = [rootLevel, ...open];
|
|
225
|
+
const levels = useQueries({
|
|
226
|
+
queries: parents.map((parent) => ({
|
|
227
|
+
queryKey: levelKey(parent),
|
|
228
|
+
queryFn: async () =>
|
|
229
|
+
parent.type === "flow" && parent.id !== null
|
|
230
|
+
? // The same row a folder's level builds for a flow, from the same function: what hangs
|
|
231
|
+
// under an expanded flow differs in where the flows come from, never in what a flow row
|
|
232
|
+
// is (#30).
|
|
233
|
+
await flowCallRows(parent.id)
|
|
234
|
+
: await data.listTreeChildren(parent.id),
|
|
235
|
+
})),
|
|
236
|
+
});
|
|
237
|
+
const levelFor = (level: Level) =>
|
|
238
|
+
levels[parents.findIndex((entry) => entry.id === level.id && entry.type === level.type)];
|
|
239
|
+
const root = levels[0];
|
|
240
|
+
|
|
241
|
+
// ⚠️ The list the reader opened is also trimmed here, and not only where it is read: `open`
|
|
242
|
+
// above keeps a departed level from being LOADED, but the entry itself would otherwise sit in
|
|
243
|
+
// `expanded` until the tab is closed — and a folder that comes back would come back open, which
|
|
244
|
+
// is a claim about a gesture nobody made in this tree. A gesture is where trimming is free: it
|
|
245
|
+
// is an event, so there is no render to loop through.
|
|
246
|
+
//
|
|
247
|
+
function toggle(level: ExpandedLevel, wanted: boolean) {
|
|
248
|
+
setExpanded((current) => {
|
|
249
|
+
const rest = current.filter(
|
|
250
|
+
(entry) =>
|
|
251
|
+
(entry.id !== level.id || entry.type !== level.type) &&
|
|
252
|
+
open.some((live) => live.id === entry.id && live.type === entry.type),
|
|
253
|
+
);
|
|
254
|
+
return wanted ? [...rest, level] : rest;
|
|
255
|
+
});
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/**
|
|
259
|
+
* The tree opens itself down to whatever is selected, and this is the one place that does it
|
|
260
|
+
* (#809). Everything else about a selection is drawing: `isActive` marks a row the tree happens to
|
|
261
|
+
* be showing anyway, which for a document three folders down is nothing at all — the reader sees
|
|
262
|
+
* the document open beside a tree that says nothing about where it is.
|
|
263
|
+
*
|
|
264
|
+
* ⚠️ ONCE per selection, and the ref is what makes it once. This effect runs on every render while
|
|
265
|
+
* the selection stands, so opening the trail each time would take a folder the reader has just
|
|
266
|
+
* closed and open it again: the tree would be arguing with the hand. It is deliberately NOT reset
|
|
267
|
+
* when the selection goes away either — coming back to the same row through Tools or the archive
|
|
268
|
+
* is the same selection, and it has been revealed already.
|
|
269
|
+
*
|
|
270
|
+
* ⚠️ A row the tree already draws costs NOTHING. That is not an optimisation to be dropped: an
|
|
271
|
+
* ordinary click in the tree would otherwise read one node per level above it, on a surface that
|
|
272
|
+
* stands on every screen of the application.
|
|
273
|
+
*/
|
|
274
|
+
const selectedArea: RevealArea | null =
|
|
275
|
+
location.pathname === "/nodes" ? "nodes" : location.pathname === "/flows" ? "flows" : null;
|
|
276
|
+
const drawn = (id: string) =>
|
|
277
|
+
[rootLevel, ...open].some((level) =>
|
|
278
|
+
(queryClient.getQueryData<TreeEntry[]>(levelKey(level)) ?? []).some(
|
|
279
|
+
(entry) => entry.id === id,
|
|
280
|
+
),
|
|
281
|
+
);
|
|
282
|
+
|
|
283
|
+
// ⚠️ Written through, not merely held. A ref dies with the component, and this component dies
|
|
284
|
+
// every time the sidebar is collapsed — while `expanded` survives, because that is what #805
|
|
285
|
+
// built. Held in memory alone, a folder the reader closed by hand would be opened again by the
|
|
286
|
+
// next mount, by a reveal that had forgotten it already happened.
|
|
287
|
+
function remember(selected: string) {
|
|
288
|
+
revealedFor.current = selected;
|
|
289
|
+
expansion.write({ levels: expanded, revealed: selected });
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
useEffect(() => {
|
|
293
|
+
const selected = location.select;
|
|
294
|
+
if (selected === null || selectedArea === null) return;
|
|
295
|
+
if (revealedFor.current === selected) return;
|
|
296
|
+
if (drawn(selected)) {
|
|
297
|
+
remember(selected);
|
|
298
|
+
return;
|
|
299
|
+
}
|
|
300
|
+
remember(selected);
|
|
301
|
+
void folderTrail(
|
|
302
|
+
{
|
|
303
|
+
// ⚠️ Through the query client, and under the keys the screens themselves use: the selected
|
|
304
|
+
// row is the one thing the screen beside this tree has already asked for, so the first step
|
|
305
|
+
// of the trail is normally answered from the cache and costs no request at all.
|
|
306
|
+
node: async (nodeId) =>
|
|
307
|
+
(
|
|
308
|
+
await queryClient.fetchQuery({
|
|
309
|
+
queryKey: ["node", nodeId],
|
|
310
|
+
queryFn: () => data.getNode(nodeId),
|
|
311
|
+
})
|
|
312
|
+
).node,
|
|
313
|
+
flow: async (flowId) =>
|
|
314
|
+
(
|
|
315
|
+
await queryClient.fetchQuery({
|
|
316
|
+
queryKey: ["flow", flowId],
|
|
317
|
+
queryFn: () => data.getFlow(flowId),
|
|
318
|
+
})
|
|
319
|
+
).flow,
|
|
320
|
+
},
|
|
321
|
+
{ id: selected, area: selectedArea },
|
|
322
|
+
).then((trail) => {
|
|
323
|
+
// ⚠️ The walk takes as long as the reads above it, and the reader does not wait: two quick
|
|
324
|
+
// clicks, or two hits from the search, start a second walk while this one is still out. The
|
|
325
|
+
// answer that comes back late would then open the folders of a row nobody is looking at any
|
|
326
|
+
// more — nothing is lost, but the tree stands open at a place the selection left.
|
|
327
|
+
if (revealedFor.current !== selected) return;
|
|
328
|
+
if (trail.length === 0) return;
|
|
329
|
+
// The updater form, because this lands after an await and the `expanded` of the render that
|
|
330
|
+
// started the walk may be several gestures old by now.
|
|
331
|
+
setExpanded((current) => {
|
|
332
|
+
const missing = trail.filter(
|
|
333
|
+
(level) => !current.some((entry) => entry.id === level.id && entry.type === level.type),
|
|
334
|
+
);
|
|
335
|
+
return missing.length === 0 ? current : [...current, ...missing];
|
|
336
|
+
});
|
|
337
|
+
});
|
|
338
|
+
});
|
|
339
|
+
|
|
340
|
+
/**
|
|
341
|
+
* ⚠️ Opening the trail is half the answer: a tree taller than the sidebar can have the row open
|
|
342
|
+
* and out of sight, which reads exactly like not having opened it. `nearest` rather than `center`,
|
|
343
|
+
* so a row that is already visible does not jump.
|
|
344
|
+
*/
|
|
345
|
+
const activeRow = useRef<HTMLButtonElement | null>(null);
|
|
346
|
+
const scrolledTo = useRef<string | null>(null);
|
|
347
|
+
useEffect(() => {
|
|
348
|
+
const selected = location.select;
|
|
349
|
+
if (selected === null || activeRow.current === null) return;
|
|
350
|
+
if (scrolledTo.current === selected) return;
|
|
351
|
+
scrolledTo.current = selected;
|
|
352
|
+
activeRow.current.scrollIntoView({ block: "nearest" });
|
|
353
|
+
});
|
|
354
|
+
|
|
355
|
+
// ⚠️ The level a row appears in is not the only thing that goes stale. The header search reads
|
|
356
|
+
// the whole flow list, and the graph is what the link picker and the flow editor's tree links
|
|
357
|
+
// offer — a row created here has to reach those too, or another screen keeps showing yesterday.
|
|
358
|
+
//
|
|
359
|
+
// ⚠️ And the level written into is not enough either: the folder's own ROW says whether it can be
|
|
360
|
+
// opened at all, and that row stands one level higher (`allTreeLevelsKey`, #519). Without it the
|
|
361
|
+
// `toggle` above opens a folder whose arrow never appeared, so the first thing filed into an empty
|
|
362
|
+
// folder was invisible until the page was reloaded.
|
|
363
|
+
async function reveal(
|
|
364
|
+
parentId: string | null,
|
|
365
|
+
collection: "flows" | "node-graph",
|
|
366
|
+
): Promise<void> {
|
|
367
|
+
if (parentId !== null) toggle({ id: parentId, type: "folder" }, true);
|
|
368
|
+
await Promise.all([
|
|
369
|
+
queryClient.invalidateQueries({ queryKey: allTreeLevelsKey }),
|
|
370
|
+
queryClient.invalidateQueries({ queryKey: [collection] }),
|
|
371
|
+
// The graph view of that same level draws exactly this list, so it goes stale for the same
|
|
372
|
+
// reason the level does (#19).
|
|
373
|
+
queryClient.invalidateQueries({ queryKey: ["relation-graph"] }),
|
|
374
|
+
]);
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
const create = useMutation({
|
|
378
|
+
mutationFn: async ({
|
|
379
|
+
parentId,
|
|
380
|
+
kind,
|
|
381
|
+
title,
|
|
382
|
+
columns,
|
|
383
|
+
}: Creating & { title: string; columns: string[] }) => {
|
|
384
|
+
if (kind === "flow") {
|
|
385
|
+
const flow = await data.createFlow({
|
|
386
|
+
parentId,
|
|
387
|
+
title,
|
|
388
|
+
description: null,
|
|
389
|
+
idempotencyKey: crypto.randomUUID(),
|
|
390
|
+
});
|
|
391
|
+
return { area: "/flows" as const, id: flow.id, parentId, collection: "flows" as const };
|
|
392
|
+
}
|
|
393
|
+
const node = await data.createNodes({
|
|
394
|
+
parentId,
|
|
395
|
+
kind,
|
|
396
|
+
title,
|
|
397
|
+
description: null,
|
|
398
|
+
idempotencyKey: crypto.randomUUID(),
|
|
399
|
+
});
|
|
400
|
+
// ⚠️ Two calls, because they are two things: the node is a row in the tree, the header is the
|
|
401
|
+
// contract every later append is measured against (#40). A table without a header would sit
|
|
402
|
+
// there refusing every append, so the second call happens here rather than being left to the
|
|
403
|
+
// person who created it.
|
|
404
|
+
if (kind === "table") {
|
|
405
|
+
await data.defineTable({
|
|
406
|
+
nodeId: node.id,
|
|
407
|
+
columns,
|
|
408
|
+
idempotencyKey: crypto.randomUUID(),
|
|
409
|
+
});
|
|
410
|
+
}
|
|
411
|
+
/**
|
|
412
|
+
* ⚠️ **A board's columns are written at creation, in the language of whoever made it** (#675).
|
|
413
|
+
* The server has a fallback for a board nobody configured, but it cannot know the reader's
|
|
414
|
+
* language — and columns are SHARED data: two people on one board must read the same names,
|
|
415
|
+
* and the settings dialog edits one list. Translating per reader would give them different
|
|
416
|
+
* boards. Written once, they are data anybody can rename.
|
|
417
|
+
*
|
|
418
|
+
* ⚠️ It is a second call and it may fail on its own. That is survivable and deliberately not
|
|
419
|
+
* repaired here: the board then answers with the server's fallback and is usable, which is
|
|
420
|
+
* the same state a board made over MCP is in. A failure that made the whole creation fail
|
|
421
|
+
* would trade a usable board for none.
|
|
422
|
+
*/
|
|
423
|
+
if (kind === "board") {
|
|
424
|
+
try {
|
|
425
|
+
await data.updateBoard({
|
|
426
|
+
boardId: node.id,
|
|
427
|
+
columns: [
|
|
428
|
+
{ id: "backlog", title: i18n.t("board.column.default.backlog"), terminal: false },
|
|
429
|
+
{ id: "todo", title: i18n.t("board.column.default.todo"), terminal: false },
|
|
430
|
+
{ id: "doing", title: i18n.t("board.column.default.doing"), terminal: false },
|
|
431
|
+
{ id: "done", title: i18n.t("board.column.default.done"), terminal: true },
|
|
432
|
+
],
|
|
433
|
+
idempotencyKey: crypto.randomUUID(),
|
|
434
|
+
});
|
|
435
|
+
} catch {
|
|
436
|
+
/**
|
|
437
|
+
* ⚠️ **Swallowed on purpose, and the `catch` is what makes that true.** Without it the
|
|
438
|
+
* failure travels out of the mutation: the node exists, but `onSuccess` never runs, the
|
|
439
|
+
* screen says "creating failed", and the obvious response — try again — makes a SECOND
|
|
440
|
+
* board, because nothing tells the reader the first one is already there.
|
|
441
|
+
*
|
|
442
|
+
* What is lost by swallowing is one thing and it is repairable: the board answers with
|
|
443
|
+
* the server's fallback columns instead of the ones in this reader's language. That is
|
|
444
|
+
* exactly the state a board made over MCP is in, and the settings dialog renames them.
|
|
445
|
+
*/
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
return {
|
|
449
|
+
area: "/nodes" as const,
|
|
450
|
+
id: node.id,
|
|
451
|
+
parentId,
|
|
452
|
+
collection: "node-graph" as const,
|
|
453
|
+
};
|
|
454
|
+
},
|
|
455
|
+
onSuccess: async (created) => {
|
|
456
|
+
setCreating(null);
|
|
457
|
+
// ⚠️ The screen first, the refreshes after. Since #519 `reveal` waits on EVERY mounted level,
|
|
458
|
+
// and a React Query retry is paused while the tab is not in front (#212) — awaiting that
|
|
459
|
+
// before navigating would leave somebody who just made a document sitting on the old screen
|
|
460
|
+
// for as long as one level takes to answer.
|
|
461
|
+
//
|
|
462
|
+
// ⚠️ `finally`, because both screens are lazy chunks: a failed chunk load throws out of
|
|
463
|
+
// `navigate`, and without this the tree, the collections and the graph would silently keep
|
|
464
|
+
// yesterday's rows for the rest of the session — the very state this ticket is about.
|
|
465
|
+
try {
|
|
466
|
+
await navigate({ to: created.area, search: { select: created.id } });
|
|
467
|
+
} finally {
|
|
468
|
+
await reveal(created.parentId, created.collection);
|
|
469
|
+
}
|
|
470
|
+
},
|
|
471
|
+
});
|
|
472
|
+
|
|
473
|
+
// What a drop on this target would do. `undefined` means nothing is being dragged, so the target
|
|
474
|
+
// is not a target at all.
|
|
475
|
+
function verdictFor(
|
|
476
|
+
carried: Carried | null,
|
|
477
|
+
targetId: string | null,
|
|
478
|
+
targetAncestors: ReadonlySet<string>,
|
|
479
|
+
) {
|
|
480
|
+
if (!carried) return undefined;
|
|
481
|
+
return moveVerdict({
|
|
482
|
+
draggedId: carried.entry.id,
|
|
483
|
+
draggedParentId: parentOf(carried.entry),
|
|
484
|
+
draggedLevel: carried.level,
|
|
485
|
+
targetId,
|
|
486
|
+
targetAncestors,
|
|
487
|
+
});
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
function dropHandlers(target: MoveDestination, ancestors: ReadonlySet<string>) {
|
|
491
|
+
// Two readings of the same question. The render-time one drives what is on screen; the handlers
|
|
492
|
+
// ask `draggedRef` instead, because a drag's events can all arrive before React has re-rendered
|
|
493
|
+
// and a closure over the previous render would then move the previous row.
|
|
494
|
+
const verdict = verdictFor(dragged, target.id, ancestors);
|
|
495
|
+
return {
|
|
496
|
+
verdict,
|
|
497
|
+
props: {
|
|
498
|
+
onDragOver: (event: React.DragEvent) => {
|
|
499
|
+
if (verdictFor(draggedRef.current, target.id, ancestors) !== "ok") {
|
|
500
|
+
event.dataTransfer.dropEffect = "none";
|
|
501
|
+
return;
|
|
502
|
+
}
|
|
503
|
+
// Without `preventDefault` the browser refuses the drop, so this is also what makes the
|
|
504
|
+
// "not allowed" cursor appear over every target the tree has ruled out.
|
|
505
|
+
event.preventDefault();
|
|
506
|
+
event.dataTransfer.dropEffect = "move";
|
|
507
|
+
setOver(target.id);
|
|
508
|
+
},
|
|
509
|
+
onDragLeave: () => setOver((current) => (current === target.id ? undefined : current)),
|
|
510
|
+
onDrop: (event: React.DragEvent) => {
|
|
511
|
+
event.preventDefault();
|
|
512
|
+
const carried = draggedRef.current;
|
|
513
|
+
setOver(undefined);
|
|
514
|
+
setDragged(null);
|
|
515
|
+
draggedRef.current = null;
|
|
516
|
+
if (carried && verdictFor(carried, target.id, ancestors) === "ok") {
|
|
517
|
+
move.start(carried.entry, carried.level, target);
|
|
518
|
+
}
|
|
519
|
+
},
|
|
520
|
+
},
|
|
521
|
+
};
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
function renderLevel(parent: Level, ancestors: ReadonlySet<string>, label?: string) {
|
|
525
|
+
const level = levelFor(parent);
|
|
526
|
+
if (!level || level.isPending) {
|
|
527
|
+
return (
|
|
528
|
+
<SidebarMenu aria-label={label}>
|
|
529
|
+
{[0, 1, 2].map((row) => (
|
|
530
|
+
<SidebarMenuItem key={row}>
|
|
531
|
+
<SidebarMenuSkeleton showIcon />
|
|
532
|
+
</SidebarMenuItem>
|
|
533
|
+
))}
|
|
534
|
+
</SidebarMenu>
|
|
535
|
+
);
|
|
536
|
+
}
|
|
537
|
+
// ⚠️ A refusal, not a failure (#430). While this branch did not exist, a reader whose share had
|
|
538
|
+
// been revoked — or who holds no `intel.nodes.read` at all — watched the three skeleton bars
|
|
539
|
+
// above for as long as the retries lasted, and the retries are paused whenever the tab is not
|
|
540
|
+
// in front. Neither of those two cases is worth trying again, so what stands here is a sentence
|
|
541
|
+
// and no button.
|
|
542
|
+
const refusal = refusalOf(level.error);
|
|
543
|
+
if (refusal) {
|
|
544
|
+
return (
|
|
545
|
+
<p role="status" className="px-2 py-1.5 text-sm text-muted-foreground">
|
|
546
|
+
{i18n.t(refusal === "no-permission" ? "common.noPermission" : "common.noAccess")}
|
|
547
|
+
</p>
|
|
548
|
+
);
|
|
549
|
+
}
|
|
550
|
+
if (level.isError) {
|
|
551
|
+
return (
|
|
552
|
+
<div role="alert" className="space-y-2 px-2 py-1.5 text-sm">
|
|
553
|
+
<p className="text-destructive">{i18n.t("tree.failed")}</p>
|
|
554
|
+
<button
|
|
555
|
+
type="button"
|
|
556
|
+
onClick={() => void level.refetch()}
|
|
557
|
+
className="rounded-md border px-2 py-1 text-xs outline-none hover:bg-sidebar-accent focus-visible:ring-2 focus-visible:ring-sidebar-ring"
|
|
558
|
+
>
|
|
559
|
+
{i18n.t("common.retry")}
|
|
560
|
+
</button>
|
|
561
|
+
</div>
|
|
562
|
+
);
|
|
563
|
+
}
|
|
564
|
+
// ⚠️ The cycle guard, in the form a per-level load needs: a broken `parentId` that points back
|
|
565
|
+
// up would otherwise open the same folder inside itself for as long as anyone keeps clicking.
|
|
566
|
+
// The recursion is gone, so the depth limit that used to save us is gone with it; what stops it
|
|
567
|
+
// now is that a row already on the path from the root is not rendered again.
|
|
568
|
+
// ⚠️ The folder's own text is not one of its contents (#824): a document called `index`
|
|
569
|
+
// describes the folder it is in, and it appears in that folder's details rather than as a row
|
|
570
|
+
// under it. `folderContentsOf` is the one place that decides which row that is.
|
|
571
|
+
const entries =
|
|
572
|
+
folderContentsOf(parent.id, level.data ?? []).filter((entry) => !ancestors.has(entry.id)) ??
|
|
573
|
+
[];
|
|
574
|
+
if (entries.length === 0) {
|
|
575
|
+
// ⚠️ Nothing that is empty says so any more (#59). "This flow calls no other flow" used to
|
|
576
|
+
// stand here as an answer rather than filler — but a row that cannot be opened is the answer,
|
|
577
|
+
// and now no such row can be opened: the chevron only appears where there is something under
|
|
578
|
+
// it. The sentence would only ever be read by somebody who got here another way.
|
|
579
|
+
//
|
|
580
|
+
// `tree.empty` at the ROOT stays, and for a different reason that still holds: a completely
|
|
581
|
+
// blank sidebar on a fresh installation leaves a new user without any way in.
|
|
582
|
+
const message = parent.type === "folder" && parent.id === null ? i18n.t("tree.empty") : null;
|
|
583
|
+
// The folder keeps its list, empty. The structure is what carries "there is nothing here" to
|
|
584
|
+
// a screen reader — "list, 0 items" — which is the same answer the missing rows give a reader
|
|
585
|
+
// who can see them, and neither of them is a sentence.
|
|
586
|
+
return message === null ? (
|
|
587
|
+
<SidebarMenu aria-label={label} />
|
|
588
|
+
) : (
|
|
589
|
+
<p className="px-2 py-1.5 text-sm text-muted-foreground">{message}</p>
|
|
590
|
+
);
|
|
591
|
+
}
|
|
592
|
+
return (
|
|
593
|
+
<SidebarMenu aria-label={label}>
|
|
594
|
+
{entries.map((entry) => renderRow(entry, parent, ancestors))}
|
|
595
|
+
</SidebarMenu>
|
|
596
|
+
);
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
// ⚠️ A level under a FLOW is derived: its rows are that flow's calls, read out of its graph
|
|
600
|
+
// (ADR-0004 §3, `levelKey`). Nothing there has a `parent_id` to rewrite, so those rows are neither
|
|
601
|
+
// dragged nor dropped on — a move made there would silently be a move of the shared flow itself.
|
|
602
|
+
function renderRow(entry: TreeEntry, parent: Level, ancestors: ReadonlySet<string>) {
|
|
603
|
+
const derived = parent.type === "flow";
|
|
604
|
+
const isFolder = entry.kind === "folder";
|
|
605
|
+
// ⚠️ A flow expands too, and what appears under it is what it calls — read from its graph, not
|
|
606
|
+
// from `parent_id`. A flow reused by three callers therefore shows up under all three, which is
|
|
607
|
+
// the answer to "what do I run, and how" (ADR-0004 §3).
|
|
608
|
+
const level: Level & { id: string } = { id: entry.id, type: isFolder ? "folder" : "flow" };
|
|
609
|
+
// ⚠️ No longer "is of a kind that can be expanded", but "has something to expand that this
|
|
610
|
+
// reader may see" (#59). The difference is an arrow that keeps its promise — before, it stood
|
|
611
|
+
// on every folder and every flow, empty ones included.
|
|
612
|
+
const expandable = entry.openable;
|
|
613
|
+
// ⚠️ `open`, not `expanded`: what is drawn and what is loaded answer the same question, or the
|
|
614
|
+
// tree would draw a level it never asked for (#521).
|
|
615
|
+
const isOpen =
|
|
616
|
+
expandable && open.some((live) => live.id === entry.id && live.type === level.type);
|
|
617
|
+
const Icon = isOpen && isFolder ? FolderOpen : iconFor(entry);
|
|
618
|
+
const area = entry.type === "flow" ? "/flows" : "/nodes";
|
|
619
|
+
const isActive = location.select === entry.id && location.pathname === area;
|
|
620
|
+
// A row's plus files into that row's place: inside a folder, beside anything else.
|
|
621
|
+
//
|
|
622
|
+
// ⚠️ "Beside it" is the level this row is shown IN, not the parent its own record names. Since
|
|
623
|
+
// #429 the root level also carries rows whose `parentId` points at a folder this reader may not
|
|
624
|
+
// see — a shared document is filed inside the sharer's folder, and only the share reaches down
|
|
625
|
+
// to it. Filing beside such a row through that id would try to write into a folder the reader
|
|
626
|
+
// cannot even open, and the refusal would arrive as a bare "could not be created".
|
|
627
|
+
//
|
|
628
|
+
// ⚠️ A derived level is the exception, because it has no folder to be filed in: what a flow
|
|
629
|
+
// calls is read from its graph, so the record's own parent is the only answer available there.
|
|
630
|
+
//
|
|
631
|
+
// ⚠️ It is the only one, not a safe one. A called flow may be a LIBRARY flow — `execute` without
|
|
632
|
+
// `read`, which ADR-0004 §2/§3 allows on purpose — and its folder is then one this reader may
|
|
633
|
+
// not open at all. The plus there can still be refused, exactly as the plus on any read-only
|
|
634
|
+
// folder row elsewhere in the tree has always been able to be. That is the older question of
|
|
635
|
+
// what a plus should do where writing is not allowed, and it is not this ticket's: what #429
|
|
636
|
+
// changed is a plus pointing at a folder that is not even the level being shown.
|
|
637
|
+
const target = isFolder ? entry.id : derived ? parentOf(entry) : parent.id;
|
|
638
|
+
// Only a folder takes a drop, and only on a level that owns its rows. Everything else keeps the
|
|
639
|
+
// default cursor while a drag is in progress, which is the answer "not here" without a word.
|
|
640
|
+
const drop =
|
|
641
|
+
isFolder && !derived ? dropHandlers({ id: entry.id, title: entry.title }, ancestors) : null;
|
|
642
|
+
const isDragged = dragged?.entry.id === entry.id;
|
|
643
|
+
// While a drag is in progress every row says which of the three it is: the row being carried,
|
|
644
|
+
// a folder that would take it, or something that would not. Silence on the last two is what
|
|
645
|
+
// turns a drag into a guess that only the drop answers.
|
|
646
|
+
const highlight =
|
|
647
|
+
dragged === null || isDragged
|
|
648
|
+
? ""
|
|
649
|
+
: drop?.verdict === "ok"
|
|
650
|
+
? over === entry.id
|
|
651
|
+
? "bg-sidebar-accent ring-2 ring-sidebar-ring"
|
|
652
|
+
: "ring-1 ring-sidebar-border"
|
|
653
|
+
: "opacity-40";
|
|
654
|
+
|
|
655
|
+
return (
|
|
656
|
+
<SidebarMenuItem key={entry.id}>
|
|
657
|
+
{/* ⚠️ The row becomes the field, rather than growing one beside it (#823). A row that keeps
|
|
658
|
+
its button while an input sits next to it has two things claiming the same name, and the
|
|
659
|
+
plus and the drag handle would still be there for a row that is, for the moment, a
|
|
660
|
+
sentence being typed. */}
|
|
661
|
+
{renaming?.id === entry.id ? (
|
|
662
|
+
<RenameRow entry={renaming} close={() => setRenaming(null)} />
|
|
663
|
+
) : (
|
|
664
|
+
<>
|
|
665
|
+
{/* ⚠️ `group/row` belongs HERE and not on the `SidebarMenuItem` above, which is what #252
|
|
666
|
+
was: the item also holds the open sub-level, and `group-hover/row` is a DESCENDANT
|
|
667
|
+
selector — every plus further down matched the ancestor's hover, so pointing at one
|
|
668
|
+
folder lit up its whole open subtree. This div holds one row and none of its children,
|
|
669
|
+
which is the only element on which "the row is hovered" means that one row.
|
|
670
|
+
|
|
671
|
+
⚠️ The colouring sits on the ROW, not on the button inside it — the difference the
|
|
672
|
+
ticket is about. It used to hang on `SidebarMenuButton`, whose siblings the plus and this
|
|
673
|
+
menu are, so the grey stopped short of them: two icons standing outside the very row they
|
|
674
|
+
operate, with a pale strip left over on the right. Taken from gate, where the same
|
|
675
|
+
mistake was made and unmade three times (`service-tree.tsx`, GATE-76/-78/-81) — there the
|
|
676
|
+
row carries the fill and `flex-1` on the label pushes the actions to the end *inside* it.
|
|
677
|
+
The button keeps its own hover and active fill switched off rather than doubled, so the
|
|
678
|
+
row shows one surface instead of two overlapping ones. */}
|
|
679
|
+
{/* biome-ignore lint/a11y/noStaticElementInteractions: Native drag is the pointer shortcut;
|
|
680
|
+
the nested button and move dialog remain the keyboard path. */}
|
|
681
|
+
<div
|
|
682
|
+
draggable={!derived}
|
|
683
|
+
onDragStart={(event) => {
|
|
684
|
+
// ⚠️ `draggable={!derived}` is not enough on its own: `dragstart` bubbles, so a drag
|
|
685
|
+
// begun on a descendant of a derived row would still land here — and carry a level
|
|
686
|
+
// (`["tree", <flow-id>]`) that nothing ever wrote. That is the very class of key #446
|
|
687
|
+
// is about, so the handler refuses it rather than trusting the attribute.
|
|
688
|
+
if (derived) return;
|
|
689
|
+
// Native drag initiation on form controls is not interoperable: Safari can leave a
|
|
690
|
+
// draggable button looking grabbable without ever starting the drag. The row owns the
|
|
691
|
+
// gesture while its button remains the keyboard-reachable navigation control (#483).
|
|
692
|
+
event.dataTransfer.effectAllowed = "copyMove";
|
|
693
|
+
event.dataTransfer.setData("text/plain", entry.id);
|
|
694
|
+
event.dataTransfer.setData(
|
|
695
|
+
TreeEntryMediaType,
|
|
696
|
+
JSON.stringify({ id: entry.id, kind: entry.kind, title: entry.title }),
|
|
697
|
+
);
|
|
698
|
+
// The level travels with the row, read from the level this row was rendered into —
|
|
699
|
+
// never from its own record, which for a shared row names a folder the reader cannot
|
|
700
|
+
// see (#446).
|
|
701
|
+
const carried: Carried = { entry, level: parent.id };
|
|
702
|
+
draggedRef.current = carried;
|
|
703
|
+
setDragged(carried);
|
|
704
|
+
}}
|
|
705
|
+
onDragEnd={() => {
|
|
706
|
+
draggedRef.current = null;
|
|
707
|
+
setDragged(null);
|
|
708
|
+
setOver(undefined);
|
|
709
|
+
}}
|
|
710
|
+
{...(drop?.props ?? {})}
|
|
711
|
+
className={`group/row flex items-center gap-0.5 rounded-md pr-1 transition-colors hover:bg-sidebar-accent hover:text-sidebar-accent-foreground ${
|
|
712
|
+
isActive ? "bg-sidebar-accent font-medium text-sidebar-accent-foreground" : ""
|
|
713
|
+
} ${derived ? "" : "cursor-grab active:cursor-grabbing"}`}
|
|
714
|
+
>
|
|
715
|
+
{expandable ? (
|
|
716
|
+
<button
|
|
717
|
+
type="button"
|
|
718
|
+
aria-expanded={isOpen}
|
|
719
|
+
aria-label={i18n.t(
|
|
720
|
+
isFolder
|
|
721
|
+
? isOpen
|
|
722
|
+
? "tree.collapse"
|
|
723
|
+
: "tree.expand"
|
|
724
|
+
: isOpen
|
|
725
|
+
? "tree.collapseCalls"
|
|
726
|
+
: "tree.expandCalls",
|
|
727
|
+
{ title: entry.title },
|
|
728
|
+
)}
|
|
729
|
+
onClick={() => toggle(level, !isOpen)}
|
|
730
|
+
// The icons inside a coloured row need a step of their own, or their hover would be
|
|
731
|
+
// the row's colour on the row's colour and read as nothing at all.
|
|
732
|
+
//
|
|
733
|
+
// ⚠️ A square of exactly `TREE_GUTTER` with the icon CENTRED in it (#453). It used to
|
|
734
|
+
// be `p-1` around a 14px icon — 22px wide, so the arrow's centre sat at 11px while the
|
|
735
|
+
// line of the level below started at 14px: the line ran PAST the arrow instead of
|
|
736
|
+
// under it. On the fourth level that line is the only thing left saying whose content
|
|
737
|
+
// one is reading, and missing its arrow it reads as a separation instead of a
|
|
738
|
+
// belonging. Centred, the hit area also gains the air on the left it never had, and it
|
|
739
|
+
// gets bigger rather than smaller — an arrow that sits well but is harder to hit is no
|
|
740
|
+
// improvement, on touch least of all.
|
|
741
|
+
className={`${TREE_GUTTER} flex items-center justify-center rounded-md text-sidebar-foreground/70 outline-none hover:bg-sidebar-accent-foreground/10 focus-visible:ring-2 focus-visible:ring-sidebar-ring`}
|
|
742
|
+
>
|
|
743
|
+
<ChevronRight
|
|
744
|
+
aria-hidden="true"
|
|
745
|
+
className={`size-3.5 transition-transform ${isOpen ? "rotate-90" : ""}`}
|
|
746
|
+
/>
|
|
747
|
+
</button>
|
|
748
|
+
) : (
|
|
749
|
+
// ⚠️ The SAME width as the button, not one step less: a row without an arrow used to sit
|
|
750
|
+
// 2px left of one with an arrow, and nothing said why.
|
|
751
|
+
<span aria-hidden="true" className={TREE_GUTTER} />
|
|
752
|
+
)}
|
|
753
|
+
{/* Navigation stays a real button. The enclosing row carries the native drag gesture;
|
|
754
|
+
putting `draggable` on this form control is precisely the Safari failure from #483. */}
|
|
755
|
+
<SidebarMenuButton
|
|
756
|
+
ref={isActive ? activeRow : null}
|
|
757
|
+
/**
|
|
758
|
+
* ⚠️ A double click OPENS and then renames, and that is the decided behaviour rather
|
|
759
|
+
* than an accident (#823): the first of the two clicks is an ordinary click, and an
|
|
760
|
+
* ordinary click on a row opens it. Swallowing the navigation would mean telling a
|
|
761
|
+
* single click from a double one before either has finished — a delay on every click
|
|
762
|
+
* in the tree, paid by everybody, so that nobody ever sees the row they clicked.
|
|
763
|
+
*
|
|
764
|
+
* ⚠️ F2 is the same act for a keyboard, and the tree has no other way to reach it: the
|
|
765
|
+
* three-dot menu is not in the row (#58), it is in the title line of the open thing.
|
|
766
|
+
*/
|
|
767
|
+
onDoubleClick={() => setRenaming(entry)}
|
|
768
|
+
onKeyDown={(event) => {
|
|
769
|
+
if (event.key === "F2") {
|
|
770
|
+
event.preventDefault();
|
|
771
|
+
setRenaming(entry);
|
|
772
|
+
}
|
|
773
|
+
}}
|
|
774
|
+
isActive={isActive}
|
|
775
|
+
/* ⚠️ What says the row is movable BEFORE anything touches it (#590). Until now only
|
|
776
|
+
`cursor-grab` said it, and that is a statement addressed to a pointer that is already
|
|
777
|
+
on the row — nobody navigating by keyboard, by touch or by ear ever received it, and
|
|
778
|
+
a way nobody sees is a way nobody uses. That is how the dead gesture of #483 stayed
|
|
779
|
+
dead for months without a single report.
|
|
780
|
+
|
|
781
|
+
⚠️ A DESCRIPTION and not part of the name: the name is the title, and twenty rows
|
|
782
|
+
each announcing an instruction would bury it. It is also one shared element rather
|
|
783
|
+
than one per row — the sentence is the same for every movable row, and a copy per row
|
|
784
|
+
is a copy per row to keep in step.
|
|
785
|
+
|
|
786
|
+
⚠️ Absent on a derived row, and that is the whole discrimination: what a flow calls
|
|
787
|
+
has no `parent_id` to rewrite and is not draggable (`draggable={!derived}`), so
|
|
788
|
+
claiming it were movable would send somebody after a gesture that is refused. */
|
|
789
|
+
aria-describedby={derived ? undefined : movableId}
|
|
790
|
+
data-drop={
|
|
791
|
+
dragged === null ? undefined : isDragged ? "dragged" : (drop?.verdict ?? "none")
|
|
792
|
+
}
|
|
793
|
+
className={`min-w-0 flex-1 hover:bg-transparent data-[active=true]:bg-transparent ${isDragged ? "opacity-50" : ""} ${highlight}`}
|
|
794
|
+
onClick={() => void navigate({ to: area, search: { select: entry.id } })}
|
|
795
|
+
>
|
|
796
|
+
<Icon aria-hidden="true" className="size-4 shrink-0" />
|
|
797
|
+
<span className="truncate">{entry.title}</span>
|
|
798
|
+
{/* The icon carries the type on screen; this carries it everywhere else, and it is why
|
|
799
|
+
a flow needs no suffix in its name to be told from a document. */}
|
|
800
|
+
<span className="sr-only">{i18n.t(`tree.kind.${entry.kind}`)}</span>
|
|
801
|
+
</SidebarMenuButton>
|
|
802
|
+
<AddMenu
|
|
803
|
+
label={i18n.t("tree.add", { title: entry.title })}
|
|
804
|
+
onSelect={(kind) => {
|
|
805
|
+
if (kind === "import") nodeImport.open(target);
|
|
806
|
+
else setCreating({ parentId: target, kind });
|
|
807
|
+
}}
|
|
808
|
+
/>
|
|
809
|
+
{/* ⚠️ Nothing else. The three-dot menu stood here until #58 and does not any more: two
|
|
810
|
+
buttons per row put ten of them into a 240px column, and one then reads buttons instead
|
|
811
|
+
of titles — the tree is for reading. Every action it held is in the title line of the
|
|
812
|
+
thing itself, which every row opens by being clicked, including a folder, whose screen
|
|
813
|
+
is nothing but that line. Do not put a second one back here: the point of the menu is
|
|
814
|
+
that it is always in the same place, and two places are not one. */}
|
|
815
|
+
</div>
|
|
816
|
+
</>
|
|
817
|
+
)}
|
|
818
|
+
{/* The same rows one indent deeper: one row component for every depth, so the plus on the
|
|
819
|
+
fourth level is the same plus as on the first. The list is named after the row it hangs
|
|
820
|
+
under — indentation says whose contents these are to anyone who can see it, and this is
|
|
821
|
+
the same sentence for anyone who cannot. */}
|
|
822
|
+
{isOpen ? (
|
|
823
|
+
<div className={TREE_LINE}>
|
|
824
|
+
{renderLevel(
|
|
825
|
+
level,
|
|
826
|
+
new Set([...ancestors, entry.id]),
|
|
827
|
+
i18n.t(isFolder ? "tree.contents" : "tree.calls", { title: entry.title }),
|
|
828
|
+
)}
|
|
829
|
+
</div>
|
|
830
|
+
) : null}
|
|
831
|
+
</SidebarMenuItem>
|
|
832
|
+
);
|
|
833
|
+
}
|
|
834
|
+
|
|
835
|
+
const rootDrop = dropHandlers({ id: null, title: i18n.t("tree.move.root") }, new Set());
|
|
836
|
+
|
|
837
|
+
return (
|
|
838
|
+
<SidebarGroup className="min-h-0 flex-1 overflow-y-auto">
|
|
839
|
+
{/* The tree is the navigation and carries no heading of its own (ADR-0004); the list is
|
|
840
|
+
named for assistive technology instead. */}
|
|
841
|
+
{renderLevel({ id: null, type: "folder" }, new Set(), i18n.t("tree.label"))}
|
|
842
|
+
{/* ⚠️ The sentence every movable row points at (#590). It names BOTH ways on purpose: the
|
|
843
|
+
drag for whoever has a pointer, and the entry in the title line for whoever has not —
|
|
844
|
+
`resource.move` is the same word it carries there. Naming only the drag would describe a
|
|
845
|
+
gesture that keyboard and touch do not have, which is the state this ticket found.
|
|
846
|
+
|
|
847
|
+
⚠️ It is rendered AFTER the rows and it is `sr-only`, so it takes no space and moves
|
|
848
|
+
nothing. Anything a row could be pushed down by is the abort of #483 all over again — and
|
|
849
|
+
an element that exists from the first render pushes nothing in any case. */}
|
|
850
|
+
<span id={movableId} className="sr-only">
|
|
851
|
+
{i18n.t("tree.move.rowHint")}
|
|
852
|
+
</span>
|
|
853
|
+
{/* The root has no row to drop on, so while something is being carried it gets one. It is the
|
|
854
|
+
only way back out of a folder, and it appears exactly when it can be used.
|
|
855
|
+
⚠️ BELOW the tree, and that is not a matter of taste (#483). It used to stand above it, so
|
|
856
|
+
the moment a drag began every row moved down by its height — including the row being
|
|
857
|
+
dragged. Chromium ABORTS a drag whose source shifts under it: `dragstart` fired, `dragend`
|
|
858
|
+
followed six milliseconds later, and no `drag`, `dragover` or `drop` ever came. The hand
|
|
859
|
+
appeared and nothing moved, which is exactly what the ticket describes. Below the tree the
|
|
860
|
+
strip pushes nothing that sits above it, so the source stays where the gesture started.
|
|
861
|
+
Measured in a real Chromium: with the strip above, the gesture dies; with it here, the same
|
|
862
|
+
drag reaches the drop and opens the move dialog. */}
|
|
863
|
+
{rootDrop.verdict === undefined || dragged === null ? null : (
|
|
864
|
+
<button
|
|
865
|
+
type="button"
|
|
866
|
+
{...rootDrop.props}
|
|
867
|
+
onClick={() => {
|
|
868
|
+
// The strip is drawn for every verdict — greyed out when it would refuse — so the click
|
|
869
|
+
// has to read it too. Without this, a click on the grey strip performs exactly the write
|
|
870
|
+
// the drop path refuses, and `initial` being set skips the picker where the verdict is
|
|
871
|
+
// otherwise enforced.
|
|
872
|
+
if (rootDrop.verdict !== "ok") return;
|
|
873
|
+
move.start(dragged.entry, dragged.level, {
|
|
874
|
+
id: null,
|
|
875
|
+
title: i18n.t("tree.move.root"),
|
|
876
|
+
});
|
|
877
|
+
}}
|
|
878
|
+
data-drop={rootDrop.verdict}
|
|
879
|
+
className={`mt-1 flex w-full items-center gap-1 rounded-md border border-dashed px-2 py-1.5 text-left text-sm outline-none focus-visible:ring-2 focus-visible:ring-sidebar-ring ${
|
|
880
|
+
rootDrop.verdict === "ok"
|
|
881
|
+
? over === null
|
|
882
|
+
? "bg-sidebar-accent ring-2 ring-sidebar-ring"
|
|
883
|
+
: "text-sidebar-foreground"
|
|
884
|
+
: "text-muted-foreground opacity-40"
|
|
885
|
+
}`}
|
|
886
|
+
>
|
|
887
|
+
<CornerLeftUp aria-hidden="true" className="size-4 shrink-0" />
|
|
888
|
+
{i18n.t("tree.move.dropRoot")}
|
|
889
|
+
</button>
|
|
890
|
+
)}
|
|
891
|
+
{create.isError ? (
|
|
892
|
+
<p role="alert" className="px-2 py-1.5 text-sm text-destructive">
|
|
893
|
+
{i18n.t("tree.createFailed")}
|
|
894
|
+
</p>
|
|
895
|
+
) : null}
|
|
896
|
+
{/* Four refusals, four sentences — and by the time one is read the row is already back where
|
|
897
|
+
it started, because the rollback happens in `onError` rather than here. */}
|
|
898
|
+
{move.error ? (
|
|
899
|
+
<p role="alert" className="px-2 py-1.5 text-sm text-destructive">
|
|
900
|
+
{i18n.t(moveErrorKey(move.error))}
|
|
901
|
+
</p>
|
|
902
|
+
) : null}
|
|
903
|
+
{/* The root has no row of its own, so it gets one — otherwise a tree could only ever grow
|
|
904
|
+
inside the folders it already has, and the plus would stand loose under the list, attached
|
|
905
|
+
to nothing (#24). As a whole row it is also the one thing on screen when the tree is empty,
|
|
906
|
+
which is exactly when somebody needs to be told where to start. */}
|
|
907
|
+
{root?.isPending ? null : (
|
|
908
|
+
<div className="mt-1 flex items-center gap-0.5">
|
|
909
|
+
{/* ⚠️ No gutter here any more (#453). It was meant to line the plus up with the row ICONS
|
|
910
|
+
above it — and the price was that the row stood half a step right of everything
|
|
911
|
+
belonging to the top level, so it read as a child of the list while it means the list
|
|
912
|
+
itself. It begins on the same left edge as the arrows of the top level, which is the
|
|
913
|
+
edge that says "this level". */}
|
|
914
|
+
<AddMenu
|
|
915
|
+
label={i18n.t("tree.addRoot")}
|
|
916
|
+
description={i18n.t("tree.addRoot.long")}
|
|
917
|
+
variant="row"
|
|
918
|
+
onSelect={(kind) => {
|
|
919
|
+
if (kind === "import") nodeImport.open(null);
|
|
920
|
+
else setCreating({ parentId: null, kind });
|
|
921
|
+
}}
|
|
922
|
+
/>
|
|
923
|
+
</div>
|
|
924
|
+
)}
|
|
925
|
+
{nodeImport.inputs}
|
|
926
|
+
{creating ? (
|
|
927
|
+
<Modal title={i18n.t(`tree.new.${creating.kind}`)} close={() => setCreating(null)}>
|
|
928
|
+
<CreateForm
|
|
929
|
+
kind={creating.kind}
|
|
930
|
+
pending={create.isPending}
|
|
931
|
+
submit={(title, columns) => create.mutate({ ...creating, title, columns })}
|
|
932
|
+
/>
|
|
933
|
+
</Modal>
|
|
934
|
+
) : null}
|
|
935
|
+
{move.dialog}
|
|
936
|
+
</SidebarGroup>
|
|
937
|
+
);
|
|
938
|
+
}
|
|
939
|
+
|
|
940
|
+
// ── The geometry of the tree: two measurements that belong together (#453) ───────────────────────
|
|
941
|
+
//
|
|
942
|
+
// The disclosure arrow sits in a 24px SQUARE, and the vertical line of an open level starts at 12px
|
|
943
|
+
// — the centre column of that square. The two used to be arrived at independently (`p-1` around a
|
|
944
|
+
// 14px icon = 22px, line at `ml-3.5` = 14px), which is why the line ran BESIDE its arrow rather than
|
|
945
|
+
// underneath it.
|
|
946
|
+
//
|
|
947
|
+
// ⚠️ The result is no small matter: on the fourth level the line is the only thing left saying whose
|
|
948
|
+
// content you are reading. If it misses its arrow, it reads as a separation instead of a belonging.
|
|
949
|
+
//
|
|
950
|
+
// That is why the two stand HERE side by side: whoever changes one sees the other.
|
|
951
|
+
const TREE_GUTTER = "size-6 shrink-0";
|
|
952
|
+
const TREE_LINE = "ml-3 border-l border-sidebar-border pl-2";
|
|
953
|
+
|
|
954
|
+
// ⚠️ Hover alone would hide this from a keyboard and from touch entirely, which is the same as not
|
|
955
|
+
// having it. It stays in the tab order, focus makes it visible, and below `md` it is always shown.
|
|
956
|
+
//
|
|
957
|
+
// `row` is the root's form: a full-width row that says what it does, because the root has no title
|
|
958
|
+
// beside which a bare icon would mean anything.
|
|
959
|
+
//
|
|
960
|
+
// ⚠️ Both variants offer the same things since #822, and that is a change from #346 rather than a
|
|
961
|
+
// slip: the root used to be the only plus with an import, because a folder had one in its own
|
|
962
|
+
// three-dot menu — but the plus of a folder row already carried "Upload file", so a folder row
|
|
963
|
+
// could bring in a file and the three-dot menu next to it could not. One word now covers all three
|
|
964
|
+
// sources, in both menus, and the difference disappears with it.
|
|
965
|
+
//
|
|
966
|
+
// ⚠️ In the `row` form the visible text and the accessible name are NOT the same string (#453). The
|
|
967
|
+
// sentence "Add at the top level" was cut to "Add at the top le…" in a 240px sidebar —
|
|
968
|
+
// a truncated sentence says less than a short word. The word is what one reads; the sentence stays
|
|
969
|
+
// as the name, because this row is the only way to start a tree and the only thing on screen when
|
|
970
|
+
// the tree is empty. Shorter must not mean that nobody is told where to begin.
|
|
971
|
+
type AddMenuProps =
|
|
972
|
+
| { label: string; description: string; variant: "row"; onSelect(kind: MenuChoice): void }
|
|
973
|
+
| { label: string; variant?: "icon"; onSelect(kind: MenuChoice): void };
|
|
974
|
+
|
|
975
|
+
// What the plus can answer with: one of the kinds it makes, or the one word for everything that
|
|
976
|
+
// comes in from outside.
|
|
977
|
+
type MenuChoice = NewKind | "import";
|
|
978
|
+
|
|
979
|
+
function AddMenu(props: AddMenuProps) {
|
|
980
|
+
const { label, onSelect } = props;
|
|
981
|
+
const variant = props.variant ?? "icon";
|
|
982
|
+
const i18n = useI18n();
|
|
983
|
+
// ⚠️ Two groups, and the separator is the answer to what the plus promises: five kinds are MADE
|
|
984
|
+
// here, and everything below the line comes IN from somewhere else (#346, #822). The icons come
|
|
985
|
+
// from the one kind-icon map rather than being named again — a second `Folder` beside it is how
|
|
986
|
+
// the tree row and this menu start disagreeing.
|
|
987
|
+
const kinds: Array<{ kind: NewKind; labelKey: string; Icon: typeof Plus }> = [
|
|
988
|
+
{ kind: "folder", labelKey: "tree.new.folder", Icon: kindIcons.folder },
|
|
989
|
+
{ kind: "document", labelKey: "tree.new.document", Icon: kindIcons.document },
|
|
990
|
+
{ kind: "table", labelKey: "tree.new.table", Icon: kindIcons.table },
|
|
991
|
+
// ⚠️ A board needs nothing else to be usable: an unconfigured one answers with three default
|
|
992
|
+
// columns (`DEFAULT_COLUMNS`, `packages/intel/src/boards/boards.ts`), so unlike a table it has no
|
|
993
|
+
// second call here. A board with no columns would be a screen with nowhere to put a card.
|
|
994
|
+
{ kind: "board", labelKey: "tree.new.board", Icon: kindIcons.board },
|
|
995
|
+
{ kind: "flow", labelKey: "tree.new.flow", Icon: kindIcons.flow },
|
|
996
|
+
];
|
|
997
|
+
return (
|
|
998
|
+
<DropdownMenu>
|
|
999
|
+
<DropdownMenuTrigger
|
|
1000
|
+
aria-label={props.variant === "row" ? props.description : label}
|
|
1001
|
+
className={
|
|
1002
|
+
variant === "row"
|
|
1003
|
+
? "flex min-w-0 flex-1 items-center gap-2 rounded-md px-2 py-1.5 text-left text-sm text-sidebar-foreground/70 outline-none transition-colors hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 focus-visible:ring-sidebar-ring data-[state=open]:bg-sidebar-accent"
|
|
1004
|
+
: "shrink-0 rounded-md p-1 text-sidebar-foreground outline-none transition-opacity hover:bg-sidebar-accent-foreground/10 focus-visible:opacity-100 focus-visible:ring-2 focus-visible:ring-sidebar-ring data-[state=open]:opacity-100 group-focus-within/row:opacity-100 group-hover/row:opacity-100 md:opacity-0"
|
|
1005
|
+
}
|
|
1006
|
+
>
|
|
1007
|
+
<Plus aria-hidden="true" className="size-4 shrink-0" />
|
|
1008
|
+
{variant === "row" ? <span className="truncate">{label}</span> : null}
|
|
1009
|
+
</DropdownMenuTrigger>
|
|
1010
|
+
<DropdownMenuContent align="start" className="w-44">
|
|
1011
|
+
{kinds.map((item) => (
|
|
1012
|
+
<DropdownMenuItem key={item.kind} onSelect={() => onSelect(item.kind)}>
|
|
1013
|
+
<item.Icon aria-hidden="true" />
|
|
1014
|
+
{i18n.t(item.labelKey)}
|
|
1015
|
+
</DropdownMenuItem>
|
|
1016
|
+
))}
|
|
1017
|
+
<DropdownMenuSeparator />
|
|
1018
|
+
{/* ⚠️ One entry for three sources, and the window behind it is the same one the folder's
|
|
1019
|
+
three-dot menu opens (#822). Three entries stood here — a file, a folder, a zip — and
|
|
1020
|
+
two of them only at the root, which is how a folder ended up able to take a zip but not
|
|
1021
|
+
a file. */}
|
|
1022
|
+
<DropdownMenuItem onSelect={() => onSelect("import")}>
|
|
1023
|
+
<Upload aria-hidden="true" />
|
|
1024
|
+
{i18n.t("resource.import")}
|
|
1025
|
+
</DropdownMenuItem>
|
|
1026
|
+
</DropdownMenuContent>
|
|
1027
|
+
</DropdownMenu>
|
|
1028
|
+
);
|
|
1029
|
+
}
|
|
1030
|
+
|
|
1031
|
+
// The column names of a new table, as one line. A comma-separated list is what a CSV header is,
|
|
1032
|
+
// so the field looks like the thing it produces rather than like a form that hides it.
|
|
1033
|
+
function columnsOf(value: string): string[] {
|
|
1034
|
+
return value
|
|
1035
|
+
.split(",")
|
|
1036
|
+
.map((column) => column.trim())
|
|
1037
|
+
.filter(Boolean);
|
|
1038
|
+
}
|
|
1039
|
+
|
|
1040
|
+
/**
|
|
1041
|
+
* One row of the tree, while its name is being typed (#823).
|
|
1042
|
+
*
|
|
1043
|
+
* ⚠️ It renames through the same seam the three-dot menu uses — `useResourceRename` — and that is
|
|
1044
|
+
* the whole reason the seam exists. Both have to send `baseUpdatedAt` and both have to refresh the
|
|
1045
|
+
* same four places; two implementations of that are two chances for one of them to be short one.
|
|
1046
|
+
*
|
|
1047
|
+
* ⚠️ **Escape cancels and blur cancels too.** Committing on blur would write a name nobody pressed
|
|
1048
|
+
* a key to confirm — a click meant for another row would rename this one, and a rename that
|
|
1049
|
+
* happened by accident is one nobody goes looking for. What blur costs is the typing, which is one
|
|
1050
|
+
* word.
|
|
1051
|
+
*
|
|
1052
|
+
* ⚠️ An empty name is refused rather than saved, and so is an unchanged one — the second silently:
|
|
1053
|
+
* there is nothing to write, so the row simply closes rather than spending a request and a version
|
|
1054
|
+
* on the name it already had.
|
|
1055
|
+
*/
|
|
1056
|
+
function RenameRow({ entry, close }: { entry: TreeEntry; close(): void }) {
|
|
1057
|
+
const i18n = useI18n();
|
|
1058
|
+
const [next, setNext] = useState(entry.title);
|
|
1059
|
+
const rename = useResourceRename(
|
|
1060
|
+
entry.type === "flow" ? { type: "flow", flow: entry.flow } : { type: "node", node: entry.node },
|
|
1061
|
+
close,
|
|
1062
|
+
);
|
|
1063
|
+
const trimmed = next.trim();
|
|
1064
|
+
return (
|
|
1065
|
+
<form
|
|
1066
|
+
className="flex items-center gap-0.5 rounded-md px-2 py-1"
|
|
1067
|
+
onSubmit={(event) => {
|
|
1068
|
+
event.preventDefault();
|
|
1069
|
+
if (rename.isPending || trimmed.length === 0) return;
|
|
1070
|
+
if (trimmed === entry.title) {
|
|
1071
|
+
close();
|
|
1072
|
+
return;
|
|
1073
|
+
}
|
|
1074
|
+
rename.mutate(trimmed);
|
|
1075
|
+
}}
|
|
1076
|
+
>
|
|
1077
|
+
<div className="min-w-0 flex-1">
|
|
1078
|
+
<input
|
|
1079
|
+
// biome-ignore lint/a11y/noAutofocus: the row turned into this field to take this value
|
|
1080
|
+
autoFocus
|
|
1081
|
+
value={next}
|
|
1082
|
+
disabled={rename.isPending}
|
|
1083
|
+
aria-label={i18n.t("resource.renameTitle", { title: entry.title })}
|
|
1084
|
+
onChange={(event) => setNext(event.target.value)}
|
|
1085
|
+
/**
|
|
1086
|
+
* ⚠️ **Not while the write is in flight.** The field is `disabled` for as long as the
|
|
1087
|
+
* request runs, and disabling a FOCUSED element makes a real browser blur it — Chromium
|
|
1088
|
+
* and Firefox both run the focus fixup of the standard. An unguarded `onBlur` would then
|
|
1089
|
+
* close the row in the middle of its own write, and a `409` coming back a moment later
|
|
1090
|
+
* would have nowhere left to be shown: the conflict guard this ticket is about would be
|
|
1091
|
+
* silent on screen while refusing correctly on the wire (found in review of #837).
|
|
1092
|
+
*
|
|
1093
|
+
* ⚠️ jsdom does not do the fixup, so no test in this package can see the bug — only the
|
|
1094
|
+
* guard. The test beside it drives the blur by hand for exactly that reason.
|
|
1095
|
+
*/
|
|
1096
|
+
onBlur={() => {
|
|
1097
|
+
if (!rename.isPending) close();
|
|
1098
|
+
}}
|
|
1099
|
+
onKeyDown={(event) => {
|
|
1100
|
+
if (event.key === "Escape") {
|
|
1101
|
+
event.preventDefault();
|
|
1102
|
+
close();
|
|
1103
|
+
}
|
|
1104
|
+
}}
|
|
1105
|
+
className="w-full rounded-md border bg-background px-2 py-1 text-sm outline-none focus-visible:ring-2 focus-visible:ring-sidebar-ring"
|
|
1106
|
+
/>
|
|
1107
|
+
{rename.isError ? (
|
|
1108
|
+
<p role="alert" className="px-1 py-1 text-xs text-destructive">
|
|
1109
|
+
{i18n.t(resourceErrorKey(rename.error))}
|
|
1110
|
+
</p>
|
|
1111
|
+
) : null}
|
|
1112
|
+
</div>
|
|
1113
|
+
</form>
|
|
1114
|
+
);
|
|
1115
|
+
}
|
|
1116
|
+
|
|
1117
|
+
function CreateForm({
|
|
1118
|
+
kind,
|
|
1119
|
+
pending,
|
|
1120
|
+
submit,
|
|
1121
|
+
}: {
|
|
1122
|
+
kind: NewKind;
|
|
1123
|
+
pending: boolean;
|
|
1124
|
+
submit(title: string, columns: string[]): void;
|
|
1125
|
+
}) {
|
|
1126
|
+
const i18n = useI18n();
|
|
1127
|
+
const [title, setTitle] = useState("");
|
|
1128
|
+
const [columns, setColumns] = useState("");
|
|
1129
|
+
const parsedColumns = columnsOf(columns);
|
|
1130
|
+
const incomplete = title.trim().length === 0 || (kind === "table" && parsedColumns.length === 0);
|
|
1131
|
+
return (
|
|
1132
|
+
<form
|
|
1133
|
+
className="space-y-4"
|
|
1134
|
+
onSubmit={(event) => {
|
|
1135
|
+
event.preventDefault();
|
|
1136
|
+
if (!pending && !incomplete) submit(title.trim(), parsedColumns);
|
|
1137
|
+
}}
|
|
1138
|
+
>
|
|
1139
|
+
<label className="block text-sm font-medium">
|
|
1140
|
+
{i18n.t("common.title")}
|
|
1141
|
+
<input
|
|
1142
|
+
required
|
|
1143
|
+
value={title}
|
|
1144
|
+
onChange={(event) => setTitle(event.target.value)}
|
|
1145
|
+
className="mt-2 w-full rounded-md border bg-background px-3 py-2 outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
|
1146
|
+
/>
|
|
1147
|
+
</label>
|
|
1148
|
+
{kind === "table" ? (
|
|
1149
|
+
<label className="block text-sm font-medium">
|
|
1150
|
+
{i18n.t("tree.table.columns")}
|
|
1151
|
+
<input
|
|
1152
|
+
required
|
|
1153
|
+
value={columns}
|
|
1154
|
+
onChange={(event) => setColumns(event.target.value)}
|
|
1155
|
+
aria-describedby="create-table-columns-hint"
|
|
1156
|
+
className="mt-2 w-full rounded-md border bg-background px-3 py-2 outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
|
1157
|
+
/>
|
|
1158
|
+
<span
|
|
1159
|
+
id="create-table-columns-hint"
|
|
1160
|
+
className="mt-1 block text-xs font-normal text-muted-foreground"
|
|
1161
|
+
>
|
|
1162
|
+
{i18n.t("tree.table.columnsHint")}
|
|
1163
|
+
</span>
|
|
1164
|
+
</label>
|
|
1165
|
+
) : null}
|
|
1166
|
+
<button
|
|
1167
|
+
type="submit"
|
|
1168
|
+
disabled={pending || incomplete}
|
|
1169
|
+
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"
|
|
1170
|
+
>
|
|
1171
|
+
{pending ? i18n.t("common.saving") : i18n.t("common.create")}
|
|
1172
|
+
</button>
|
|
1173
|
+
</form>
|
|
1174
|
+
);
|
|
1175
|
+
}
|