wiki-viewer 1.14.1 → 1.14.2
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/.next/standalone/.next/BUILD_ID +1 -1
- package/.next/standalone/.next/build-manifest.json +3 -3
- package/.next/standalone/.next/server/app/_global-error.html +1 -1
- package/.next/standalone/.next/server/app/_global-error.rsc +1 -1
- package/.next/standalone/.next/server/app/_global-error.segments/__PAGE__.segment.rsc +1 -1
- package/.next/standalone/.next/server/app/_global-error.segments/_full.segment.rsc +1 -1
- package/.next/standalone/.next/server/app/_global-error.segments/_head.segment.rsc +1 -1
- package/.next/standalone/.next/server/app/_global-error.segments/_index.segment.rsc +1 -1
- package/.next/standalone/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
- package/.next/standalone/.next/server/app/_not-found.html +1 -1
- package/.next/standalone/.next/server/app/_not-found.rsc +1 -1
- package/.next/standalone/.next/server/app/_not-found.segments/_full.segment.rsc +1 -1
- package/.next/standalone/.next/server/app/_not-found.segments/_head.segment.rsc +1 -1
- package/.next/standalone/.next/server/app/_not-found.segments/_index.segment.rsc +1 -1
- package/.next/standalone/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +1 -1
- package/.next/standalone/.next/server/app/_not-found.segments/_not-found.segment.rsc +1 -1
- package/.next/standalone/.next/server/app/_not-found.segments/_tree.segment.rsc +1 -1
- package/.next/standalone/.next/server/app/api/agent/activity/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/agent/events/[...path]/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/agent/files/[...path]/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/agent/fs/file/[...path]/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/agent/fs/ls/[[...path]]/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/agent/fs/move/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/agent/fs/search/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/agent/internal/span/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/agent/settings/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/agent/sidecar/[...path]/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/assets/[...path]/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/share/[token]/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/share/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/system/reveal/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/system/set-root/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/system/workspaces/[id]/open/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/system/workspaces/[id]/refresh/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/system/workspaces/[id]/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/system/workspaces/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/upload/[...path]/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/wiki/app/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/wiki/backlinks/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/wiki/content/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/wiki/download/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/wiki/file/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/wiki/folder/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/wiki/git-branches/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/wiki/git-checkout/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/wiki/git-diff/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/wiki/git-file-info/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/wiki/git-history/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/wiki/git-pull/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/wiki/move/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/wiki/new-file/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/wiki/outlinks/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/wiki/page/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/wiki/presence/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/wiki/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/wiki/search/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/wiki/slugs/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/wiki/upload/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/wiki/watch/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/index.html +1 -1
- package/.next/standalone/.next/server/app/index.rsc +1 -1
- package/.next/standalone/.next/server/app/index.segments/__PAGE__.segment.rsc +1 -1
- package/.next/standalone/.next/server/app/index.segments/_full.segment.rsc +1 -1
- package/.next/standalone/.next/server/app/index.segments/_head.segment.rsc +1 -1
- package/.next/standalone/.next/server/app/index.segments/_index.segment.rsc +1 -1
- package/.next/standalone/.next/server/app/index.segments/_tree.segment.rsc +1 -1
- package/.next/standalone/.next/server/middleware-build-manifest.js +3 -3
- package/.next/standalone/.next/server/middleware-manifest.json +1 -1
- package/.next/standalone/.next/server/pages/404.html +1 -1
- package/.next/standalone/.next/server/pages/500.html +1 -1
- package/.next/standalone/package.json +1 -1
- package/bin/postbuild.js +21 -0
- package/package.json +1 -1
- package/.next/standalone/.github/workflows/publish.yml +0 -37
- package/.next/standalone/.handoffs/archive/handoff-20260612-113332.md +0 -98
- package/.next/standalone/.handoffs/archive/handoff-20260612-141032.md +0 -73
- package/.next/standalone/.pi/ownership.json +0 -3
- package/.next/standalone/AGENTS.md +0 -114
- package/.next/standalone/DESIGN.md +0 -521
- package/.next/standalone/README.md +0 -821
- package/.next/standalone/TODO.md +0 -20
- package/.next/standalone/agents/bootstrap-prompt.md +0 -14
- package/.next/standalone/agents/wiki-viewer-skill/SKILL.md +0 -456
- package/.next/standalone/bin/postbuild.js +0 -78
- package/.next/standalone/bin/wiki-viewer.js +0 -922
- package/.next/standalone/certificates/localhost-key.pem +0 -28
- package/.next/standalone/certificates/localhost.pem +0 -24
- package/.next/standalone/docs/agent-collab-plan.md +0 -1615
- package/.next/standalone/docs/agent-collab-v2-plan.md +0 -771
- package/.next/standalone/docs/agent-fs-plan.md +0 -232
- package/.next/standalone/docs/editorial-skin-spec.md +0 -242
- package/.next/standalone/docs/file-vs-collab-authority.md +0 -124
- package/.next/standalone/docs/mobile-readiness-spec.md +0 -229
- package/.next/standalone/docs/workspacing-plan.md +0 -364
- package/.next/standalone/packages/wiki-viewer-mcp/README.md +0 -195
- package/.next/standalone/packages/wiki-viewer-mcp/bench-e2e.mjs +0 -166
- package/.next/standalone/packages/wiki-viewer-mcp/bench-latency.mjs +0 -87
- package/.next/standalone/packages/wiki-viewer-mcp/bench-payload.mjs +0 -45
- package/.next/standalone/packages/wiki-viewer-mcp/dist/http-client.js +0 -331
- package/.next/standalone/packages/wiki-viewer-mcp/dist/index.js +0 -531
- package/.next/standalone/packages/wiki-viewer-mcp/dist/register.js +0 -94
- package/.next/standalone/packages/wiki-viewer-mcp/dist/state-cache.js +0 -28
- package/.next/standalone/packages/wiki-viewer-mcp/package-lock.json +0 -1716
- package/.next/standalone/packages/wiki-viewer-mcp/package.json +0 -41
- package/.next/standalone/packages/wiki-viewer-mcp/src/http-client.ts +0 -459
- package/.next/standalone/packages/wiki-viewer-mcp/src/index.ts +0 -638
- package/.next/standalone/packages/wiki-viewer-mcp/src/register.ts +0 -144
- package/.next/standalone/packages/wiki-viewer-mcp/src/state-cache.ts +0 -55
- package/.next/standalone/packages/wiki-viewer-mcp/tsconfig.json +0 -19
- package/.next/standalone/pnpm-lock.yaml +0 -6697
- package/.next/standalone/pnpm-workspace.yaml +0 -8
- package/.next/standalone/postcss.config.mjs +0 -2
- package/.next/standalone/src/app/api/agent/activity/route.ts +0 -59
- package/.next/standalone/src/app/api/agent/admin/agents/[agentId]/revoke/route.ts +0 -40
- package/.next/standalone/src/app/api/agent/admin/agents/route.ts +0 -33
- package/.next/standalone/src/app/api/agent/admin/registrations/[regId]/approve/route.ts +0 -83
- package/.next/standalone/src/app/api/agent/admin/registrations/[regId]/deny/route.ts +0 -36
- package/.next/standalone/src/app/api/agent/admin/registrations/route.ts +0 -27
- package/.next/standalone/src/app/api/agent/events/[...path]/route.ts +0 -148
- package/.next/standalone/src/app/api/agent/files/[...path]/route.ts +0 -219
- package/.next/standalone/src/app/api/agent/fs/file/[...path]/route.ts +0 -446
- package/.next/standalone/src/app/api/agent/fs/ls/[[...path]]/route.ts +0 -152
- package/.next/standalone/src/app/api/agent/fs/move/route.ts +0 -130
- package/.next/standalone/src/app/api/agent/fs/search/route.ts +0 -272
- package/.next/standalone/src/app/api/agent/internal/span/route.ts +0 -121
- package/.next/standalone/src/app/api/agent/register/[regId]/route.ts +0 -50
- package/.next/standalone/src/app/api/agent/register/route.ts +0 -118
- package/.next/standalone/src/app/api/agent/settings/route.ts +0 -34
- package/.next/standalone/src/app/api/agent/settings/token/regenerate/route.ts +0 -20
- package/.next/standalone/src/app/api/agent/sidecar/[...path]/route.ts +0 -53
- package/.next/standalone/src/app/api/agents/install/route.ts +0 -143
- package/.next/standalone/src/app/api/agents/skill/route.ts +0 -20
- package/.next/standalone/src/app/api/agents/skill.tar.gz/route.ts +0 -87
- package/.next/standalone/src/app/api/app-proxy/[...path]/route.ts +0 -247
- package/.next/standalone/src/app/api/assets/[...path]/route.ts +0 -75
- package/.next/standalone/src/app/api/auth/[...all]/route.ts +0 -7
- package/.next/standalone/src/app/api/owner/init/route.ts +0 -14
- package/.next/standalone/src/app/api/share/[token]/route.ts +0 -269
- package/.next/standalone/src/app/api/share/route.ts +0 -127
- package/.next/standalone/src/app/api/system/admins/route.ts +0 -103
- package/.next/standalone/src/app/api/system/auth-config/route.ts +0 -22
- package/.next/standalone/src/app/api/system/auth-settings/route.ts +0 -86
- package/.next/standalone/src/app/api/system/browse/route.ts +0 -82
- package/.next/standalone/src/app/api/system/clear-root/route.ts +0 -16
- package/.next/standalone/src/app/api/system/config/route.ts +0 -14
- package/.next/standalone/src/app/api/system/pins/route.ts +0 -38
- package/.next/standalone/src/app/api/system/reveal/route.ts +0 -36
- package/.next/standalone/src/app/api/system/root-status/route.ts +0 -13
- package/.next/standalone/src/app/api/system/set-root/route.ts +0 -53
- package/.next/standalone/src/app/api/system/users/route.ts +0 -120
- package/.next/standalone/src/app/api/system/workspaces/[id]/open/route.ts +0 -34
- package/.next/standalone/src/app/api/system/workspaces/[id]/refresh/route.ts +0 -32
- package/.next/standalone/src/app/api/system/workspaces/[id]/route.ts +0 -78
- package/.next/standalone/src/app/api/system/workspaces/route.ts +0 -128
- package/.next/standalone/src/app/api/upload/[...path]/route.ts +0 -85
- package/.next/standalone/src/app/api/wiki/app/route.ts +0 -60
- package/.next/standalone/src/app/api/wiki/backlinks/route.ts +0 -29
- package/.next/standalone/src/app/api/wiki/content/route.ts +0 -159
- package/.next/standalone/src/app/api/wiki/download/route.ts +0 -100
- package/.next/standalone/src/app/api/wiki/file/route.ts +0 -57
- package/.next/standalone/src/app/api/wiki/folder/route.ts +0 -34
- package/.next/standalone/src/app/api/wiki/git-branches/route.ts +0 -32
- package/.next/standalone/src/app/api/wiki/git-checkout/route.ts +0 -45
- package/.next/standalone/src/app/api/wiki/git-diff/route.ts +0 -33
- package/.next/standalone/src/app/api/wiki/git-file-info/route.ts +0 -27
- package/.next/standalone/src/app/api/wiki/git-history/route.ts +0 -27
- package/.next/standalone/src/app/api/wiki/git-pull/route.ts +0 -48
- package/.next/standalone/src/app/api/wiki/move/route.ts +0 -61
- package/.next/standalone/src/app/api/wiki/new-file/route.ts +0 -56
- package/.next/standalone/src/app/api/wiki/outlinks/route.ts +0 -76
- package/.next/standalone/src/app/api/wiki/page/route.ts +0 -92
- package/.next/standalone/src/app/api/wiki/presence/route.ts +0 -56
- package/.next/standalone/src/app/api/wiki/route.ts +0 -116
- package/.next/standalone/src/app/api/wiki/search/route.ts +0 -44
- package/.next/standalone/src/app/api/wiki/slugs/route.ts +0 -54
- package/.next/standalone/src/app/api/wiki/upload/route.ts +0 -95
- package/.next/standalone/src/app/api/wiki/watch/route.ts +0 -67
- package/.next/standalone/src/app/globals.css +0 -1249
- package/.next/standalone/src/app/icon.svg +0 -11
- package/.next/standalone/src/app/layout.tsx +0 -78
- package/.next/standalone/src/app/manifest.ts +0 -30
- package/.next/standalone/src/app/page.tsx +0 -3260
- package/.next/standalone/src/app/s/[token]/page.tsx +0 -357
- package/.next/standalone/src/app/signin/page.tsx +0 -16
- package/.next/standalone/src/app/signin/signin-form.tsx +0 -238
- package/.next/standalone/src/components/ai-panel/activity-row.tsx +0 -284
- package/.next/standalone/src/components/ai-panel/ai-panel.tsx +0 -311
- package/.next/standalone/src/components/ai-panel/token-section.tsx +0 -313
- package/.next/standalone/src/components/auth-settings-sheet.tsx +0 -474
- package/.next/standalone/src/components/confirm-dialog.tsx +0 -56
- package/.next/standalone/src/components/dir-picker.tsx +0 -606
- package/.next/standalone/src/components/editor/backlinks-panel.tsx +0 -139
- package/.next/standalone/src/components/editor/bubble-menu.tsx +0 -346
- package/.next/standalone/src/components/editor/callout-extension.ts +0 -36
- package/.next/standalone/src/components/editor/comment-pip.tsx +0 -56
- package/.next/standalone/src/components/editor/comment-thread.tsx +0 -254
- package/.next/standalone/src/components/editor/csv-viewer.tsx +0 -379
- package/.next/standalone/src/components/editor/document-outline.tsx +0 -230
- package/.next/standalone/src/components/editor/editor-toolbar.tsx +0 -564
- package/.next/standalone/src/components/editor/editor.tsx +0 -1327
- package/.next/standalone/src/components/editor/extensions/drag-handle.ts +0 -325
- package/.next/standalone/src/components/editor/extensions/heading-anchors.ts +0 -55
- package/.next/standalone/src/components/editor/extensions/proof-span.ts +0 -60
- package/.next/standalone/src/components/editor/extensions/resizable-image.tsx +0 -159
- package/.next/standalone/src/components/editor/extensions.ts +0 -150
- package/.next/standalone/src/components/editor/file-fallback-viewer.tsx +0 -75
- package/.next/standalone/src/components/editor/folder-index.tsx +0 -236
- package/.next/standalone/src/components/editor/google-doc-viewer.tsx +0 -73
- package/.next/standalone/src/components/editor/image-viewer.tsx +0 -57
- package/.next/standalone/src/components/editor/large-file-gate.tsx +0 -108
- package/.next/standalone/src/components/editor/link-popover.tsx +0 -111
- package/.next/standalone/src/components/editor/media-popover.tsx +0 -212
- package/.next/standalone/src/components/editor/media-viewer.tsx +0 -70
- package/.next/standalone/src/components/editor/mermaid-viewer.tsx +0 -286
- package/.next/standalone/src/components/editor/node-app-viewer.tsx +0 -328
- package/.next/standalone/src/components/editor/notebook-viewer.tsx +0 -371
- package/.next/standalone/src/components/editor/office/docx-viewer.tsx +0 -93
- package/.next/standalone/src/components/editor/office/office-chrome.tsx +0 -83
- package/.next/standalone/src/components/editor/office/pptx-viewer.tsx +0 -104
- package/.next/standalone/src/components/editor/office/xlsx-viewer.tsx +0 -192
- package/.next/standalone/src/components/editor/pdf-viewer.tsx +0 -32
- package/.next/standalone/src/components/editor/proof-span-popover.tsx +0 -162
- package/.next/standalone/src/components/editor/slash-commands.tsx +0 -629
- package/.next/standalone/src/components/editor/source-viewer.tsx +0 -289
- package/.next/standalone/src/components/editor/suggest-edit-popover.tsx +0 -229
- package/.next/standalone/src/components/editor/suggestion-card.tsx +0 -206
- package/.next/standalone/src/components/editor/table-menu.tsx +0 -232
- package/.next/standalone/src/components/editor/view-mode-comment-button.tsx +0 -77
- package/.next/standalone/src/components/editor/website-viewer.tsx +0 -75
- package/.next/standalone/src/components/editor/wiki-link-create-dialog.tsx +0 -189
- package/.next/standalone/src/components/editor/wiki-link-decorator.ts +0 -79
- package/.next/standalone/src/components/editor/wiki-link-extension.ts +0 -120
- package/.next/standalone/src/components/editor/wiki-link-picker.tsx +0 -250
- package/.next/standalone/src/components/layout/viewer-toolbar.tsx +0 -54
- package/.next/standalone/src/components/search/search-command-dialog.tsx +0 -246
- package/.next/standalone/src/components/search/sidebar-search-box.tsx +0 -121
- package/.next/standalone/src/components/search/snippet-text.tsx +0 -48
- package/.next/standalone/src/components/share-dialog.tsx +0 -307
- package/.next/standalone/src/components/theme-provider.tsx +0 -16
- package/.next/standalone/src/components/theme-toggle.tsx +0 -24
- package/.next/standalone/src/components/ui/badge.tsx +0 -35
- package/.next/standalone/src/components/ui/button.tsx +0 -56
- package/.next/standalone/src/components/ui/card.tsx +0 -63
- package/.next/standalone/src/components/ui/collapsible.tsx +0 -9
- package/.next/standalone/src/components/ui/command.tsx +0 -151
- package/.next/standalone/src/components/ui/context-menu.tsx +0 -191
- package/.next/standalone/src/components/ui/dialog.tsx +0 -119
- package/.next/standalone/src/components/ui/dropdown-menu.tsx +0 -194
- package/.next/standalone/src/components/ui/input.tsx +0 -22
- package/.next/standalone/src/components/ui/label.tsx +0 -25
- package/.next/standalone/src/components/ui/popover.tsx +0 -33
- package/.next/standalone/src/components/ui/scroll-area.tsx +0 -47
- package/.next/standalone/src/components/ui/select.tsx +0 -156
- package/.next/standalone/src/components/ui/separator.tsx +0 -30
- package/.next/standalone/src/components/ui/sheet.tsx +0 -140
- package/.next/standalone/src/components/ui/skeleton.tsx +0 -15
- package/.next/standalone/src/components/ui/switch.tsx +0 -28
- package/.next/standalone/src/components/ui/tabs.tsx +0 -54
- package/.next/standalone/src/components/ui/textarea.tsx +0 -21
- package/.next/standalone/src/components/ui/tip.tsx +0 -23
- package/.next/standalone/src/components/ui/toggle-group.tsx +0 -41
- package/.next/standalone/src/components/ui/tooltip.tsx +0 -27
- package/.next/standalone/src/components/view-width-toggle.tsx +0 -70
- package/.next/standalone/src/components/wiki/frontmatter-header.tsx +0 -164
- package/.next/standalone/src/hooks/use-is-mobile.ts +0 -14
- package/.next/standalone/src/lib/app-runner.ts +0 -272
- package/.next/standalone/src/lib/auth/admin.ts +0 -120
- package/.next/standalone/src/lib/auth/allowlist.ts +0 -50
- package/.next/standalone/src/lib/auth/client.ts +0 -4
- package/.next/standalone/src/lib/auth/csrf.ts +0 -68
- package/.next/standalone/src/lib/auth/server.ts +0 -215
- package/.next/standalone/src/lib/cabinets/tree.ts +0 -75
- package/.next/standalone/src/lib/config.ts +0 -114
- package/.next/standalone/src/lib/embeds/detect.ts +0 -142
- package/.next/standalone/src/lib/git-secrets.ts +0 -65
- package/.next/standalone/src/lib/git.ts +0 -503
- package/.next/standalone/src/lib/google/detect.ts +0 -107
- package/.next/standalone/src/lib/markdown/parse-frontmatter.ts +0 -104
- package/.next/standalone/src/lib/markdown/remark-wikilinks.ts +0 -100
- package/.next/standalone/src/lib/markdown/sanitize-schema.ts +0 -106
- package/.next/standalone/src/lib/markdown/to-html.ts +0 -282
- package/.next/standalone/src/lib/markdown/to-markdown.ts +0 -252
- package/.next/standalone/src/lib/proof/activity-shared.ts +0 -31
- package/.next/standalone/src/lib/proof/activity.ts +0 -74
- package/.next/standalone/src/lib/proof/audit.ts +0 -88
- package/.next/standalone/src/lib/proof/auth.ts +0 -143
- package/.next/standalone/src/lib/proof/block-refs.ts +0 -133
- package/.next/standalone/src/lib/proof/blocks.ts +0 -73
- package/.next/standalone/src/lib/proof/client-auth.ts +0 -23
- package/.next/standalone/src/lib/proof/collab-state.ts +0 -81
- package/.next/standalone/src/lib/proof/event-bus.ts +0 -47
- package/.next/standalone/src/lib/proof/file-lock.ts +0 -43
- package/.next/standalone/src/lib/proof/glob.ts +0 -51
- package/.next/standalone/src/lib/proof/idempotency.ts +0 -37
- package/.next/standalone/src/lib/proof/lease.ts +0 -117
- package/.next/standalone/src/lib/proof/mutex.ts +0 -36
- package/.next/standalone/src/lib/proof/ops-applier.ts +0 -727
- package/.next/standalone/src/lib/proof/owner-auth.ts +0 -2
- package/.next/standalone/src/lib/proof/pending.ts +0 -97
- package/.next/standalone/src/lib/proof/proof-span.ts +0 -141
- package/.next/standalone/src/lib/proof/rate-limit.ts +0 -53
- package/.next/standalone/src/lib/proof/raw-fs.ts +0 -197
- package/.next/standalone/src/lib/proof/register-rate-limit.ts +0 -53
- package/.next/standalone/src/lib/proof/registry.ts +0 -195
- package/.next/standalone/src/lib/proof/sidecar.ts +0 -103
- package/.next/standalone/src/lib/proof/suggest-capture.ts +0 -69
- package/.next/standalone/src/lib/proof/types.ts +0 -182
- package/.next/standalone/src/lib/proof-config.ts +0 -14
- package/.next/standalone/src/lib/root-dir.ts +0 -35
- package/.next/standalone/src/lib/search/indexable-exts.ts +0 -25
- package/.next/standalone/src/lib/search/indexer.ts +0 -634
- package/.next/standalone/src/lib/search/sanitize.ts +0 -35
- package/.next/standalone/src/lib/search/search-db.ts +0 -57
- package/.next/standalone/src/lib/search/watcher-pool.ts +0 -79
- package/.next/standalone/src/lib/shared-docs/db.ts +0 -177
- package/.next/standalone/src/lib/toast.ts +0 -9
- package/.next/standalone/src/lib/utils.ts +0 -6
- package/.next/standalone/src/lib/wiki-helpers.ts +0 -36
- package/.next/standalone/src/lib/workspace-client.ts +0 -82
- package/.next/standalone/src/lib/workspace-context.ts +0 -173
- package/.next/standalone/src/lib/workspaces.ts +0 -400
- package/.next/standalone/src/middleware.ts +0 -44
- package/.next/standalone/src/stores/ai-panel-store.ts +0 -79
- package/.next/standalone/src/stores/editor-store.ts +0 -439
- package/.next/standalone/src/stores/pin-store.ts +0 -56
- package/.next/standalone/src/stores/proof-store.ts +0 -124
- package/.next/standalone/src/stores/recent-store.ts +0 -51
- package/.next/standalone/src/stores/search-store.ts +0 -104
- package/.next/standalone/src/stores/show-hidden-store.ts +0 -26
- package/.next/standalone/src/stores/sidebar-width-store.ts +0 -39
- package/.next/standalone/src/stores/skin-store.ts +0 -42
- package/.next/standalone/src/stores/tree-store.ts +0 -52
- package/.next/standalone/src/stores/view-width-store.ts +0 -101
- package/.next/standalone/src/stores/wiki-slugs-store.ts +0 -75
- package/.next/standalone/src/tests/proof/activity-aggregator.test.ts +0 -146
- package/.next/standalone/src/tests/proof/admin-create-user.test.ts +0 -43
- package/.next/standalone/src/tests/proof/agent-fs.test.ts +0 -973
- package/.next/standalone/src/tests/proof/agent-ownership.test.ts +0 -140
- package/.next/standalone/src/tests/proof/agent-workspace-scope.test.ts +0 -134
- package/.next/standalone/src/tests/proof/agents-install.test.ts +0 -110
- package/.next/standalone/src/tests/proof/bench-write.ts +0 -179
- package/.next/standalone/src/tests/proof/better-auth.test.ts +0 -68
- package/.next/standalone/src/tests/proof/block-refs.test.ts +0 -108
- package/.next/standalone/src/tests/proof/blocks.test.ts +0 -92
- package/.next/standalone/src/tests/proof/collab-state.test.ts +0 -501
- package/.next/standalone/src/tests/proof/comments-ops.test.ts +0 -177
- package/.next/standalone/src/tests/proof/editor-roundtrip.test.ts +0 -85
- package/.next/standalone/src/tests/proof/external-edit.test.ts +0 -58
- package/.next/standalone/src/tests/proof/file-lock.test.ts +0 -80
- package/.next/standalone/src/tests/proof/git-branches-checkout.test.ts +0 -136
- package/.next/standalone/src/tests/proof/git-read-features.test.ts +0 -473
- package/.next/standalone/src/tests/proof/git-secrets.test.ts +0 -138
- package/.next/standalone/src/tests/proof/git-sparse.test.ts +0 -257
- package/.next/standalone/src/tests/proof/git-workspace.test.ts +0 -135
- package/.next/standalone/src/tests/proof/git.test.ts +0 -243
- package/.next/standalone/src/tests/proof/glob.test.ts +0 -82
- package/.next/standalone/src/tests/proof/helpers/auth-session.ts +0 -27
- package/.next/standalone/src/tests/proof/markdown-to-html.test.ts +0 -46
- package/.next/standalone/src/tests/proof/ops-applier.test.ts +0 -368
- package/.next/standalone/src/tests/proof/owner-init.test.ts +0 -2
- package/.next/standalone/src/tests/proof/parse-frontmatter.test.ts +0 -60
- package/.next/standalone/src/tests/proof/preload.ts +0 -21
- package/.next/standalone/src/tests/proof/proof-span.test.ts +0 -98
- package/.next/standalone/src/tests/proof/rate-limit.test.ts +0 -54
- package/.next/standalone/src/tests/proof/readonly-enforcement.test.ts +0 -161
- package/.next/standalone/src/tests/proof/reconcile-sidecar.test.ts +0 -195
- package/.next/standalone/src/tests/proof/registration-flow.test.ts +0 -330
- package/.next/standalone/src/tests/proof/registry.test.ts +0 -169
- package/.next/standalone/src/tests/proof/routes.test.ts +0 -516
- package/.next/standalone/src/tests/proof/search-index.test.ts +0 -376
- package/.next/standalone/src/tests/proof/sidecar-lifecycle.test.ts +0 -177
- package/.next/standalone/src/tests/proof/sidecar-trim.test.ts +0 -58
- package/.next/standalone/src/tests/proof/suggestion-ops.test.ts +0 -280
- package/.next/standalone/src/tests/proof/wiki-content-put.test.ts +0 -140
- package/.next/standalone/src/tests/proof/wiki-routes-auth.test.ts +0 -208
- package/.next/standalone/src/tests/proof/workspace-isolation.test.ts +0 -87
- package/.next/standalone/src/tests/proof/workspaces-api.test.ts +0 -227
- package/.next/standalone/src/types/index.ts +0 -47
- package/.next/standalone/tailwind.config.ts +0 -191
- package/.next/standalone/tsconfig.json +0 -41
- package/.next/standalone/tsconfig.tsbuildinfo +0 -1
- /package/.next/standalone/.next/static/{WMOTgfV82apmdovxzgbMx → rRyvFORvJoc5OWbCR2A_P}/_buildManifest.js +0 -0
- /package/.next/standalone/.next/static/{WMOTgfV82apmdovxzgbMx → rRyvFORvJoc5OWbCR2A_P}/_clientMiddlewareManifest.js +0 -0
- /package/.next/standalone/.next/static/{WMOTgfV82apmdovxzgbMx → rRyvFORvJoc5OWbCR2A_P}/_ssgManifest.js +0 -0
|
@@ -1,3260 +0,0 @@
|
|
|
1
|
-
"use client";
|
|
2
|
-
|
|
3
|
-
import {
|
|
4
|
-
AlertCircle,
|
|
5
|
-
Bot,
|
|
6
|
-
Check,
|
|
7
|
-
ChevronDown,
|
|
8
|
-
ChevronRight,
|
|
9
|
-
Copy,
|
|
10
|
-
Download,
|
|
11
|
-
File,
|
|
12
|
-
FilePlus,
|
|
13
|
-
FileText,
|
|
14
|
-
Folder,
|
|
15
|
-
FolderOpen,
|
|
16
|
-
FolderPlus,
|
|
17
|
-
GitBranch,
|
|
18
|
-
GitMerge,
|
|
19
|
-
Globe,
|
|
20
|
-
History,
|
|
21
|
-
User,
|
|
22
|
-
Image as ImageIcon,
|
|
23
|
-
Link,
|
|
24
|
-
Loader2,
|
|
25
|
-
Maximize2,
|
|
26
|
-
MoreHorizontal,
|
|
27
|
-
PanelLeftClose,
|
|
28
|
-
PanelLeftOpen,
|
|
29
|
-
Pencil,
|
|
30
|
-
Eye,
|
|
31
|
-
EyeOff,
|
|
32
|
-
Pin,
|
|
33
|
-
Plus,
|
|
34
|
-
RefreshCw,
|
|
35
|
-
Search,
|
|
36
|
-
Settings,
|
|
37
|
-
Share,
|
|
38
|
-
Slash,
|
|
39
|
-
SortAsc,
|
|
40
|
-
Sparkles,
|
|
41
|
-
Star,
|
|
42
|
-
Terminal,
|
|
43
|
-
Trash2,
|
|
44
|
-
Upload,
|
|
45
|
-
X,
|
|
46
|
-
} from "lucide-react";
|
|
47
|
-
import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
48
|
-
import { createPortal } from "react-dom";
|
|
49
|
-
|
|
50
|
-
import { ConfirmDialog } from "@/components/confirm-dialog";
|
|
51
|
-
import { CsvViewer } from "@/components/editor/csv-viewer";
|
|
52
|
-
import { KBEditor } from "@/components/editor/editor";
|
|
53
|
-
import { FileFallbackViewer } from "@/components/editor/file-fallback-viewer";
|
|
54
|
-
import { LargeFileGate } from "@/components/editor/large-file-gate";
|
|
55
|
-
import { ImageViewer } from "@/components/editor/image-viewer";
|
|
56
|
-
import { MediaViewer } from "@/components/editor/media-viewer";
|
|
57
|
-
import { MermaidViewer } from "@/components/editor/mermaid-viewer";
|
|
58
|
-
import { NotebookViewer } from "@/components/editor/notebook-viewer";
|
|
59
|
-
import { DocxViewer } from "@/components/editor/office/docx-viewer";
|
|
60
|
-
import { PptxViewer } from "@/components/editor/office/pptx-viewer";
|
|
61
|
-
import { XlsxViewer } from "@/components/editor/office/xlsx-viewer";
|
|
62
|
-
import { PdfViewer } from "@/components/editor/pdf-viewer";
|
|
63
|
-
import { SourceViewer } from "@/components/editor/source-viewer";
|
|
64
|
-
import { WebsiteViewer } from "@/components/editor/website-viewer";
|
|
65
|
-
import { NodeAppViewer } from "@/components/editor/node-app-viewer";
|
|
66
|
-
import { DirPicker } from "@/components/dir-picker";
|
|
67
|
-
import {
|
|
68
|
-
DropdownMenu,
|
|
69
|
-
DropdownMenuContent,
|
|
70
|
-
DropdownMenuItem,
|
|
71
|
-
DropdownMenuSeparator,
|
|
72
|
-
DropdownMenuTrigger,
|
|
73
|
-
} from "@/components/ui/dropdown-menu";
|
|
74
|
-
import { getActiveWorkspaceId, withWs, wsFetch } from "@/lib/workspace-client";
|
|
75
|
-
import { markdownToHtml } from "@/lib/markdown/to-html";
|
|
76
|
-
import { ThemeToggle } from "@/components/theme-toggle";
|
|
77
|
-
import { AuthSettingsSheet } from "@/components/auth-settings-sheet";
|
|
78
|
-
import { ShareDialog } from "@/components/share-dialog";
|
|
79
|
-
import { Button } from "@/components/ui/button";
|
|
80
|
-
import { Card } from "@/components/ui/card";
|
|
81
|
-
|
|
82
|
-
import { showError, showSuccess } from "@/lib/toast";
|
|
83
|
-
import {
|
|
84
|
-
ContextMenu,
|
|
85
|
-
ContextMenuContent,
|
|
86
|
-
ContextMenuItem,
|
|
87
|
-
ContextMenuSeparator,
|
|
88
|
-
ContextMenuTrigger,
|
|
89
|
-
} from "@/components/ui/context-menu";
|
|
90
|
-
import { useRecentStore } from "@/stores/recent-store";
|
|
91
|
-
import { usePinStore } from "@/stores/pin-store";
|
|
92
|
-
import { cn } from "@/lib/utils";
|
|
93
|
-
import { AIPanel } from "@/components/ai-panel/ai-panel";
|
|
94
|
-
import { SearchCommandDialog } from "@/components/search/search-command-dialog";
|
|
95
|
-
import { SidebarSearchBox } from "@/components/search/sidebar-search-box";
|
|
96
|
-
import { useAIPanelStore } from "@/stores/ai-panel-store";
|
|
97
|
-
import { useEditorStore, prefetchPage } from "@/stores/editor-store";
|
|
98
|
-
import {
|
|
99
|
-
useViewWidthStore,
|
|
100
|
-
VIEW_WIDTH_CLASS,
|
|
101
|
-
VIEW_ALIGN_CLASS,
|
|
102
|
-
} from "@/stores/view-width-store";
|
|
103
|
-
import {
|
|
104
|
-
useSidebarWidthStore,
|
|
105
|
-
SIDEBAR_MIN_WIDTH,
|
|
106
|
-
SIDEBAR_MAX_WIDTH,
|
|
107
|
-
} from "@/stores/sidebar-width-store";
|
|
108
|
-
import { ViewWidthToggle } from "@/components/view-width-toggle";
|
|
109
|
-
import { useWikiSlugsStore } from "@/stores/wiki-slugs-store";
|
|
110
|
-
import { useShowHiddenStore } from "@/stores/show-hidden-store";
|
|
111
|
-
import { useIsMobile } from "@/hooks/use-is-mobile";
|
|
112
|
-
|
|
113
|
-
function timeAgo(iso: string): string {
|
|
114
|
-
const t = new Date(iso).getTime();
|
|
115
|
-
if (!Number.isFinite(t)) return "";
|
|
116
|
-
const diff = Math.max(0, Date.now() - t);
|
|
117
|
-
const s = Math.floor(diff / 1000);
|
|
118
|
-
if (s < 60) return "just now";
|
|
119
|
-
const m = Math.floor(s / 60);
|
|
120
|
-
if (m < 60) return `${m}m ago`;
|
|
121
|
-
const h = Math.floor(m / 60);
|
|
122
|
-
if (h < 24) return `${h}h ago`;
|
|
123
|
-
return `${Math.floor(h / 24)}d ago`;
|
|
124
|
-
}
|
|
125
|
-
|
|
126
|
-
interface TreeNode {
|
|
127
|
-
name: string;
|
|
128
|
-
path: string;
|
|
129
|
-
type: "dir" | "file" | "app" | "node-app";
|
|
130
|
-
size?: number;
|
|
131
|
-
modifiedAt: string;
|
|
132
|
-
children?: TreeNode[];
|
|
133
|
-
expanded?: boolean;
|
|
134
|
-
loading?: boolean;
|
|
135
|
-
git?: { branch: string; dirty: boolean };
|
|
136
|
-
}
|
|
137
|
-
|
|
138
|
-
type ViewerKind =
|
|
139
|
-
| "editor"
|
|
140
|
-
| "csv"
|
|
141
|
-
| "pdf"
|
|
142
|
-
| "mermaid"
|
|
143
|
-
| "notebook"
|
|
144
|
-
| "image"
|
|
145
|
-
| "media"
|
|
146
|
-
| "docx"
|
|
147
|
-
| "xlsx"
|
|
148
|
-
| "pptx"
|
|
149
|
-
| "source"
|
|
150
|
-
| "fallback"
|
|
151
|
-
| "app"
|
|
152
|
-
| "html"
|
|
153
|
-
| "node-app"
|
|
154
|
-
| "text";
|
|
155
|
-
|
|
156
|
-
// Viewer kinds safe to open at any size: they stream, paginate, or proxy and
|
|
157
|
-
// never load the whole file into JS. Everything else goes behind LargeFileGate,
|
|
158
|
-
// so a new viewer is fail-safe by default until proven safe here.
|
|
159
|
-
const SAFE_VIEWER_KINDS = new Set<ViewerKind>([
|
|
160
|
-
"image",
|
|
161
|
-
"media",
|
|
162
|
-
"pdf",
|
|
163
|
-
"fallback",
|
|
164
|
-
"app",
|
|
165
|
-
"node-app",
|
|
166
|
-
"html",
|
|
167
|
-
]);
|
|
168
|
-
|
|
169
|
-
// Files above this size open behind a confirmation gate for unsafe viewers.
|
|
170
|
-
const LARGE_FILE_GATE_BYTES = 5 * 1024 * 1024; // 5 MB
|
|
171
|
-
|
|
172
|
-
function ext(name: string) {
|
|
173
|
-
return name.split(".").pop()?.toLowerCase() ?? "";
|
|
174
|
-
}
|
|
175
|
-
|
|
176
|
-
function viewerKindFor(
|
|
177
|
-
filename: string,
|
|
178
|
-
nodeType: "file" | "app" | "dir" | "node-app",
|
|
179
|
-
): ViewerKind {
|
|
180
|
-
if (nodeType === "node-app") return "node-app";
|
|
181
|
-
if (nodeType === "app") return "app";
|
|
182
|
-
if (nodeType === "dir") return "fallback";
|
|
183
|
-
const base = filename.split("/").pop() ?? filename;
|
|
184
|
-
// Dotfile with no real extension (".env", ".gitignore", ".bashrc"):
|
|
185
|
-
// `".env".split(".").pop()` -> "env", which would match nothing below,
|
|
186
|
-
// so treat any leading-dot name as text and let the viewer sniff bytes.
|
|
187
|
-
if (base.startsWith(".") && base.indexOf(".", 1) === -1) return "source";
|
|
188
|
-
const fileExt = ext(filename);
|
|
189
|
-
// No extension at all ("Makefile", "LICENSE", "Dockerfile"): assume text.
|
|
190
|
-
if (!fileExt) return "source";
|
|
191
|
-
if (["md", "markdown"].includes(fileExt)) return "editor";
|
|
192
|
-
if (fileExt === "txt") return "text";
|
|
193
|
-
if (["csv", "tsv"].includes(fileExt)) return "csv";
|
|
194
|
-
if (fileExt === "pdf") return "pdf";
|
|
195
|
-
if (["mmd", "mermaid"].includes(fileExt)) return "mermaid";
|
|
196
|
-
if (fileExt === "ipynb") return "notebook";
|
|
197
|
-
if (
|
|
198
|
-
["png", "jpg", "jpeg", "gif", "webp", "svg", "avif", "ico", "bmp"].includes(
|
|
199
|
-
fileExt,
|
|
200
|
-
)
|
|
201
|
-
)
|
|
202
|
-
return "image";
|
|
203
|
-
if (
|
|
204
|
-
["mp4", "webm", "mov", "m4v", "mp3", "wav", "ogg", "m4a", "aac"].includes(
|
|
205
|
-
fileExt,
|
|
206
|
-
)
|
|
207
|
-
)
|
|
208
|
-
return "media";
|
|
209
|
-
if (fileExt === "docx") return "docx";
|
|
210
|
-
if (["xlsx", "xlsm"].includes(fileExt)) return "xlsx";
|
|
211
|
-
if (fileExt === "pptx") return "pptx";
|
|
212
|
-
if (fileExt === "html") return "html";
|
|
213
|
-
if (
|
|
214
|
-
[
|
|
215
|
-
"py", "js", "ts", "tsx", "jsx", "go", "rs", "java", "c", "cpp", "h",
|
|
216
|
-
"sh", "bash", "zsh", "rb", "php", "swift", "kt", "lua", "sql", "yaml",
|
|
217
|
-
"yml", "toml", "json", "xml", "css", "scss",
|
|
218
|
-
].includes(fileExt)
|
|
219
|
-
)
|
|
220
|
-
return "source";
|
|
221
|
-
// Default: assume text and let SourceViewer sniff the bytes. If the file is
|
|
222
|
-
// actually binary, SourceViewer degrades to a download/reveal fallback.
|
|
223
|
-
// This avoids a brittle text-extension whitelist that always misses
|
|
224
|
-
// something (.env, .ini, .lock, .gradle, .properties, ...).
|
|
225
|
-
return "source";
|
|
226
|
-
}
|
|
227
|
-
|
|
228
|
-
const TEXT_EDITABLE_EXTS = new Set([
|
|
229
|
-
"txt", "md", "markdown", "json", "yaml", "yml", "toml", "csv", "tsv",
|
|
230
|
-
"xml", "html", "css", "js", "ts", "tsx", "jsx", "sh", "bash", "zsh",
|
|
231
|
-
"rb", "py", "go", "rs", "java", "c", "cpp", "h", "php", "swift", "kt",
|
|
232
|
-
"lua", "sql", "scss", "mmd", "mermaid", "ini", "env", "log", "conf",
|
|
233
|
-
]);
|
|
234
|
-
|
|
235
|
-
function isText(name: string) {
|
|
236
|
-
const kind = viewerKindFor(name, "file");
|
|
237
|
-
if (kind === "editor" || kind === "text") return true;
|
|
238
|
-
return TEXT_EDITABLE_EXTS.has(ext(name));
|
|
239
|
-
}
|
|
240
|
-
|
|
241
|
-
function isMarkdown(name: string) {
|
|
242
|
-
return ["md", "markdown"].includes(ext(name));
|
|
243
|
-
}
|
|
244
|
-
function isImage(name: string) {
|
|
245
|
-
return viewerKindFor(name, "file") === "image";
|
|
246
|
-
}
|
|
247
|
-
function isHtmlFile(name: string) {
|
|
248
|
-
return viewerKindFor(name, "file") === "html";
|
|
249
|
-
}
|
|
250
|
-
|
|
251
|
-
async function fetchDir(dir: string): Promise<TreeNode[]> {
|
|
252
|
-
const res = await wsFetch(`/api/wiki?dir=${encodeURIComponent(dir)}`);
|
|
253
|
-
if (!res.ok) return [];
|
|
254
|
-
const data: {
|
|
255
|
-
entries: Array<{
|
|
256
|
-
name: string;
|
|
257
|
-
type: "dir" | "file" | "app" | "node-app";
|
|
258
|
-
size?: number;
|
|
259
|
-
modifiedAt: string;
|
|
260
|
-
git?: { branch: string; dirty: boolean };
|
|
261
|
-
}>;
|
|
262
|
-
} = await res.json();
|
|
263
|
-
return data.entries.map((e) => ({
|
|
264
|
-
name: e.name,
|
|
265
|
-
path: dir ? `${dir}/${e.name}` : e.name,
|
|
266
|
-
type: e.type,
|
|
267
|
-
size: e.size,
|
|
268
|
-
modifiedAt: e.modifiedAt,
|
|
269
|
-
expanded: false,
|
|
270
|
-
git: e.git,
|
|
271
|
-
}));
|
|
272
|
-
}
|
|
273
|
-
|
|
274
|
-
// One-shot hover-prefetch cache for directory listings: warmed on tree-row hover,
|
|
275
|
-
// consumed (read + deleted) by the first expand so there's no long-lived staleness.
|
|
276
|
-
// reloadDir/the file-watcher always fetch fresh and bypass this.
|
|
277
|
-
const dirPrefetchCache = new Map<string, TreeNode[]>();
|
|
278
|
-
const dirPrefetchInflight = new Map<string, Promise<TreeNode[]>>();
|
|
279
|
-
|
|
280
|
-
function prefetchDir(dir: string): void {
|
|
281
|
-
if (dirPrefetchCache.has(dir) || dirPrefetchInflight.has(dir)) return;
|
|
282
|
-
const promise = fetchDir(dir)
|
|
283
|
-
.then((children) => {
|
|
284
|
-
dirPrefetchCache.set(dir, children);
|
|
285
|
-
return children;
|
|
286
|
-
})
|
|
287
|
-
.finally(() => dirPrefetchInflight.delete(dir));
|
|
288
|
-
dirPrefetchInflight.set(dir, promise);
|
|
289
|
-
promise.catch(() => {});
|
|
290
|
-
}
|
|
291
|
-
|
|
292
|
-
/** Consume a prefetched (or in-flight) dir listing, if any. One-shot. */
|
|
293
|
-
async function takePrefetchedDir(dir: string): Promise<TreeNode[] | null> {
|
|
294
|
-
const ready = dirPrefetchCache.get(dir);
|
|
295
|
-
if (ready) { dirPrefetchCache.delete(dir); return ready; }
|
|
296
|
-
const inflight = dirPrefetchInflight.get(dir);
|
|
297
|
-
if (inflight) { const r = await inflight; dirPrefetchCache.delete(dir); return r; }
|
|
298
|
-
return null;
|
|
299
|
-
}
|
|
300
|
-
|
|
301
|
-
function updateNodes(
|
|
302
|
-
nodes: TreeNode[],
|
|
303
|
-
targetPath: string,
|
|
304
|
-
updater: (n: TreeNode) => TreeNode,
|
|
305
|
-
): TreeNode[] {
|
|
306
|
-
return nodes.map((n) => {
|
|
307
|
-
if (n.path === targetPath) return updater(n);
|
|
308
|
-
if (n.children)
|
|
309
|
-
return { ...n, children: updateNodes(n.children, targetPath, updater) };
|
|
310
|
-
return n;
|
|
311
|
-
});
|
|
312
|
-
}
|
|
313
|
-
|
|
314
|
-
function removeNode(nodes: TreeNode[], targetPath: string): TreeNode[] {
|
|
315
|
-
return nodes
|
|
316
|
-
.filter((n) => n.path !== targetPath)
|
|
317
|
-
.map((n) =>
|
|
318
|
-
n.children ? { ...n, children: removeNode(n.children, targetPath) } : n,
|
|
319
|
-
);
|
|
320
|
-
}
|
|
321
|
-
|
|
322
|
-
/**
|
|
323
|
-
* Stable handler bundle passed to FileTree. Every method is referentially stable
|
|
324
|
-
* across Page() renders (backed by a ref dispatcher), so FileTree's React.memo
|
|
325
|
-
* holds and the whole tree skips re-rendering when unrelated Page state changes
|
|
326
|
-
* (dialogs, search, editor typing, sidebar resize, dropdowns, etc.).
|
|
327
|
-
*/
|
|
328
|
-
interface TreeCtx {
|
|
329
|
-
toggleFolder: (node: TreeNode) => void;
|
|
330
|
-
openViewer: (node: TreeNode) => void;
|
|
331
|
-
copyPath: (path: string) => void;
|
|
332
|
-
copyWikiLink: (name: string) => void;
|
|
333
|
-
copyUrl: (path: string) => void;
|
|
334
|
-
copyRawContent: (path: string) => void;
|
|
335
|
-
copyFormattedContent: (path: string, name: string) => void;
|
|
336
|
-
handleDownload: (node: TreeNode) => void;
|
|
337
|
-
triggerUpload: (dir: string) => void;
|
|
338
|
-
handleCreateFile: () => void;
|
|
339
|
-
handleCreateFolder: () => void;
|
|
340
|
-
handleDragStart: (e: React.DragEvent, node: TreeNode) => void;
|
|
341
|
-
handleDragOver: (e: React.DragEvent, targetPath: string, targetType: "dir" | "root") => void;
|
|
342
|
-
handleDropOnFolder: (e: React.DragEvent, targetDirPath: string) => void;
|
|
343
|
-
handleGitPull: (nodePath: string, parentDir: string) => void;
|
|
344
|
-
handleCheckout: (nodePath: string, branch: string, parentDir: string) => void;
|
|
345
|
-
loadBranches: (nodePath: string) => void;
|
|
346
|
-
prefetch: (node: TreeNode) => void;
|
|
347
|
-
togglePin: (node: TreeNode, wsId: string | null) => void;
|
|
348
|
-
setDragOverPath: (p: string | null) => void;
|
|
349
|
-
setSidebarCollapsed: (b: boolean) => void;
|
|
350
|
-
setBranchDropdownNode: (p: string | null) => void;
|
|
351
|
-
setBranchDropdownPos: (p: { top: number; left: number } | null) => void;
|
|
352
|
-
setNewFileParent: (p: string | null) => void;
|
|
353
|
-
setNewFileName: (s: string) => void;
|
|
354
|
-
setFileCreateError: (s: string | null) => void;
|
|
355
|
-
setNewFolderParent: (p: string | null) => void;
|
|
356
|
-
setNewFolderName: (s: string) => void;
|
|
357
|
-
setFolderError: (s: string | null) => void;
|
|
358
|
-
setDeletingPath: (p: string | null) => void;
|
|
359
|
-
setDeletingIsDir: (b: boolean) => void;
|
|
360
|
-
}
|
|
361
|
-
|
|
362
|
-
interface FileTreeProps {
|
|
363
|
-
ctx: TreeCtx;
|
|
364
|
-
nodes: TreeNode[];
|
|
365
|
-
openPath: string | null;
|
|
366
|
-
dragOverPath: string | null;
|
|
367
|
-
branchDropdownNode: string | null;
|
|
368
|
-
branchDropdownPos: { top: number; left: number } | null;
|
|
369
|
-
nodeBranches: Record<string, { name: string; current: boolean }[]>;
|
|
370
|
-
branchesLoading: Record<string, boolean>;
|
|
371
|
-
checkingOutBranch: string | null;
|
|
372
|
-
pullingRepo: string | null;
|
|
373
|
-
activePaths: Set<string>;
|
|
374
|
-
pins: Array<{ path: string }>;
|
|
375
|
-
isMobile: boolean;
|
|
376
|
-
activeWorkspaceId: string | null;
|
|
377
|
-
newFileParent: string | null;
|
|
378
|
-
newFileName: string;
|
|
379
|
-
fileCreateError: string | null;
|
|
380
|
-
newFolderParent: string | null;
|
|
381
|
-
newFolderName: string;
|
|
382
|
-
folderError: string | null;
|
|
383
|
-
sidebarScrollRef: React.RefObject<HTMLDivElement | null>;
|
|
384
|
-
}
|
|
385
|
-
|
|
386
|
-
// content-visibility:auto lets the browser skip layout/paint for off-screen
|
|
387
|
-
// rows — virtualization without JS scroll math, and rows stay in the DOM so
|
|
388
|
-
// keyboard nav (querySelectorAll) and Ctrl+F still work. Combined with per-row
|
|
389
|
-
// memo (below), large trees stay cheap on both the React and browser sides.
|
|
390
|
-
const ROW_CV: React.CSSProperties = { contentVisibility: "auto", containIntrinsicSize: "auto 32px" };
|
|
391
|
-
|
|
392
|
-
interface TreeRowViewProps {
|
|
393
|
-
node: TreeNode;
|
|
394
|
-
depth: number;
|
|
395
|
-
ctx: TreeCtx;
|
|
396
|
-
isMobile: boolean;
|
|
397
|
-
activeWorkspaceId: string | null;
|
|
398
|
-
sidebarScrollRef: React.RefObject<HTMLDivElement | null>;
|
|
399
|
-
isActive: boolean;
|
|
400
|
-
isDragOver: boolean;
|
|
401
|
-
isPinned: boolean;
|
|
402
|
-
isAgentActive: boolean;
|
|
403
|
-
isPulling: boolean;
|
|
404
|
-
branchOpen: boolean;
|
|
405
|
-
branchPos: { top: number; left: number } | null;
|
|
406
|
-
branches: { name: string; current: boolean }[];
|
|
407
|
-
branchLoading: boolean;
|
|
408
|
-
checkingOut: string | null;
|
|
409
|
-
onHoverEnter: (node: TreeNode) => void;
|
|
410
|
-
onHoverLeave: () => void;
|
|
411
|
-
}
|
|
412
|
-
|
|
413
|
-
// Memoized row: re-renders only when its OWN derived props change. Navigation
|
|
414
|
-
// (openPath change) re-renders just the two affected rows, not the whole tree.
|
|
415
|
-
const TreeRowView = memo(function TreeRowView({
|
|
416
|
-
node, depth, ctx, isMobile, activeWorkspaceId, sidebarScrollRef,
|
|
417
|
-
isActive, isDragOver, isPinned, isAgentActive, isPulling,
|
|
418
|
-
branchOpen, branchPos, branches, branchLoading, checkingOut,
|
|
419
|
-
onHoverEnter, onHoverLeave,
|
|
420
|
-
}: TreeRowViewProps) {
|
|
421
|
-
return (
|
|
422
|
-
<ContextMenu>
|
|
423
|
-
<ContextMenuTrigger asChild>
|
|
424
|
-
<div
|
|
425
|
-
role="treeitem"
|
|
426
|
-
tabIndex={0}
|
|
427
|
-
draggable={!isMobile}
|
|
428
|
-
onKeyDown={(e) => {
|
|
429
|
-
if (e.key === "Enter" || e.key === " ") {
|
|
430
|
-
e.preventDefault();
|
|
431
|
-
if (node.type === "dir") ctx.toggleFolder(node);
|
|
432
|
-
else if (node.type === "app" || node.type === "node-app") { ctx.openViewer(node); ctx.toggleFolder(node); }
|
|
433
|
-
else ctx.openViewer(node);
|
|
434
|
-
} else if (e.key === "ArrowDown") {
|
|
435
|
-
e.preventDefault();
|
|
436
|
-
const container = sidebarScrollRef.current;
|
|
437
|
-
if (!container) return;
|
|
438
|
-
const items = Array.from(container.querySelectorAll<HTMLElement>('[role="treeitem"]'));
|
|
439
|
-
const idx = items.indexOf(e.currentTarget as HTMLElement);
|
|
440
|
-
items[idx + 1]?.focus();
|
|
441
|
-
} else if (e.key === "ArrowUp") {
|
|
442
|
-
e.preventDefault();
|
|
443
|
-
const container = sidebarScrollRef.current;
|
|
444
|
-
if (!container) return;
|
|
445
|
-
const items = Array.from(container.querySelectorAll<HTMLElement>('[role="treeitem"]'));
|
|
446
|
-
const idx = items.indexOf(e.currentTarget as HTMLElement);
|
|
447
|
-
items[idx - 1]?.focus();
|
|
448
|
-
} else if (e.key === "ArrowRight") {
|
|
449
|
-
e.preventDefault();
|
|
450
|
-
if (node.type === "dir" || node.type === "app" || node.type === "node-app") {
|
|
451
|
-
if (!node.expanded) {
|
|
452
|
-
ctx.toggleFolder(node);
|
|
453
|
-
} else {
|
|
454
|
-
const container = sidebarScrollRef.current;
|
|
455
|
-
if (!container) return;
|
|
456
|
-
const items = Array.from(container.querySelectorAll<HTMLElement>('[role="treeitem"]'));
|
|
457
|
-
const idx = items.indexOf(e.currentTarget as HTMLElement);
|
|
458
|
-
items[idx + 1]?.focus();
|
|
459
|
-
}
|
|
460
|
-
}
|
|
461
|
-
} else if (e.key === "ArrowLeft") {
|
|
462
|
-
e.preventDefault();
|
|
463
|
-
if ((node.type === "dir" || node.type === "app" || node.type === "node-app") && node.expanded) {
|
|
464
|
-
ctx.toggleFolder(node);
|
|
465
|
-
} else if (depth > 0) {
|
|
466
|
-
const container = sidebarScrollRef.current;
|
|
467
|
-
if (!container) return;
|
|
468
|
-
const items = Array.from(container.querySelectorAll<HTMLElement>('[role="treeitem"]'));
|
|
469
|
-
const current = e.currentTarget as HTMLElement;
|
|
470
|
-
const idx = items.indexOf(current);
|
|
471
|
-
const currentPL = Number.parseInt(current.style.paddingLeft ?? "0", 10);
|
|
472
|
-
for (let i = idx - 1; i >= 0; i--) {
|
|
473
|
-
const pl = Number.parseInt(items[i].style.paddingLeft ?? "0", 10);
|
|
474
|
-
if (pl < currentPL) { items[i].focus(); break; }
|
|
475
|
-
}
|
|
476
|
-
}
|
|
477
|
-
}
|
|
478
|
-
}}
|
|
479
|
-
onDragStart={(e) => ctx.handleDragStart(e, node)}
|
|
480
|
-
onDragOver={(e) =>
|
|
481
|
-
node.type === "dir"
|
|
482
|
-
? ctx.handleDragOver(e, node.path, "dir")
|
|
483
|
-
: e.preventDefault()
|
|
484
|
-
}
|
|
485
|
-
onDragLeave={() => ctx.setDragOverPath(null)}
|
|
486
|
-
onDrop={(e) =>
|
|
487
|
-
node.type === "dir"
|
|
488
|
-
? ctx.handleDropOnFolder(e, node.path)
|
|
489
|
-
: e.preventDefault()
|
|
490
|
-
}
|
|
491
|
-
onMouseEnter={() => onHoverEnter(node)}
|
|
492
|
-
onMouseLeave={onHoverLeave}
|
|
493
|
-
className={cn(
|
|
494
|
-
"tree-row-reveal flex items-center gap-1.5 rounded-sm px-2 py-1 text-sm cursor-pointer group transition-colors select-none touch-target",
|
|
495
|
-
isActive
|
|
496
|
-
? "bg-accent-soft text-foreground font-medium"
|
|
497
|
-
: "hover:bg-muted",
|
|
498
|
-
isDragOver && "ring-2 ring-primary bg-primary-soft",
|
|
499
|
-
node.name.startsWith(".") && "opacity-40",
|
|
500
|
-
)}
|
|
501
|
-
style={{ paddingLeft: `${depth * 14 + 8}px`, ...ROW_CV }}
|
|
502
|
-
onClick={() => {
|
|
503
|
-
if (node.type === "dir") ctx.toggleFolder(node);
|
|
504
|
-
else if (node.type === "app" || node.type === "node-app") { ctx.openViewer(node); ctx.toggleFolder(node); if (isMobile) ctx.setSidebarCollapsed(true); }
|
|
505
|
-
else { ctx.openViewer(node); if (isMobile) ctx.setSidebarCollapsed(true); }
|
|
506
|
-
}}
|
|
507
|
-
>
|
|
508
|
-
{(node.type === "dir" || node.type === "app" || node.type === "node-app") ? (
|
|
509
|
-
node.loading ? (
|
|
510
|
-
<Loader2 className="h-3.5 w-3.5 shrink-0 animate-spin text-muted-foreground" />
|
|
511
|
-
) : (
|
|
512
|
-
<ChevronRight
|
|
513
|
-
className="h-3.5 w-3.5 shrink-0 text-muted-foreground transition-transform duration-200 ease-out"
|
|
514
|
-
style={{ transform: node.expanded ? "rotate(90deg)" : "rotate(0deg)" }}
|
|
515
|
-
/>
|
|
516
|
-
)
|
|
517
|
-
) : (
|
|
518
|
-
<span className="w-3.5 shrink-0" />
|
|
519
|
-
)}
|
|
520
|
-
|
|
521
|
-
<span className="editorial-tree-typeicon">{node.type === "dir" ? (
|
|
522
|
-
node.expanded ? (
|
|
523
|
-
<FolderOpen className={cn("h-4 w-4 shrink-0", !isActive && "text-warning")} />
|
|
524
|
-
) : (
|
|
525
|
-
<Folder className={cn("h-4 w-4 shrink-0", !isActive && "text-warning")} />
|
|
526
|
-
)
|
|
527
|
-
) : node.type === "app" ? (
|
|
528
|
-
<Globe className={cn("h-4 w-4 shrink-0", !isActive && "text-foreground/70")} />
|
|
529
|
-
) : node.type === "node-app" ? (
|
|
530
|
-
<Terminal className={cn("h-4 w-4 shrink-0", !isActive && "text-emerald-500")} />
|
|
531
|
-
) : isHtmlFile(node.name) ? (
|
|
532
|
-
<Globe className={cn("h-4 w-4 shrink-0", !isActive && "text-foreground/60")} />
|
|
533
|
-
) : isImage(node.name) ? (
|
|
534
|
-
<ImageIcon className={cn("h-4 w-4 shrink-0", !isActive && "text-sunshine-700")} />
|
|
535
|
-
) : isText(node.name) ? (
|
|
536
|
-
<FileText className={cn("h-4 w-4 shrink-0", !isActive && "text-foreground/70")} />
|
|
537
|
-
) : (
|
|
538
|
-
<File className={cn("h-4 w-4 shrink-0", !isActive && "text-foreground/60")} />
|
|
539
|
-
)}</span>
|
|
540
|
-
|
|
541
|
-
<span className="min-w-0 flex-1 truncate">{node.name}</span>
|
|
542
|
-
|
|
543
|
-
{/* Git repo badge */}
|
|
544
|
-
{node.git && (
|
|
545
|
-
<span className="relative flex shrink-0 items-center gap-0.5 rounded-sm bg-muted px-1 py-0.5 text-[10px] text-muted-foreground">
|
|
546
|
-
<button
|
|
547
|
-
type="button"
|
|
548
|
-
className="flex items-center gap-0.5 hover:text-foreground"
|
|
549
|
-
title="Switch branch"
|
|
550
|
-
onClick={(e) => {
|
|
551
|
-
e.stopPropagation();
|
|
552
|
-
if (branchOpen) { ctx.setBranchDropdownNode(null); ctx.setBranchDropdownPos(null); return; }
|
|
553
|
-
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
|
|
554
|
-
ctx.setBranchDropdownPos({ top: rect.bottom + 4, left: rect.left });
|
|
555
|
-
ctx.setBranchDropdownNode(node.path);
|
|
556
|
-
ctx.loadBranches(node.path);
|
|
557
|
-
}}
|
|
558
|
-
>
|
|
559
|
-
<GitBranch className="h-2.5 w-2.5" />
|
|
560
|
-
{node.git.branch}
|
|
561
|
-
{node.git.dirty && <span className="ml-0.5 text-warning">*</span>}
|
|
562
|
-
</button>
|
|
563
|
-
{isPulling ? (
|
|
564
|
-
<Loader2 className="ml-0.5 h-2.5 w-2.5 animate-spin" />
|
|
565
|
-
) : (
|
|
566
|
-
<button
|
|
567
|
-
type="button"
|
|
568
|
-
onClick={(e) => {
|
|
569
|
-
e.stopPropagation();
|
|
570
|
-
const parentDir = node.path.includes("/")
|
|
571
|
-
? node.path.substring(0, node.path.lastIndexOf("/"))
|
|
572
|
-
: "";
|
|
573
|
-
ctx.handleGitPull(node.path, parentDir);
|
|
574
|
-
}}
|
|
575
|
-
className="ml-0.5 text-muted-foreground hover:text-foreground"
|
|
576
|
-
title="Pull latest"
|
|
577
|
-
>
|
|
578
|
-
<RefreshCw className="h-2.5 w-2.5" />
|
|
579
|
-
</button>
|
|
580
|
-
)}
|
|
581
|
-
{/* Branch dropdown rendered as portal to escape overflow-hidden */}
|
|
582
|
-
{branchOpen && branchPos && typeof document !== "undefined" && createPortal(
|
|
583
|
-
<div
|
|
584
|
-
style={{ position: "fixed", top: branchPos.top, left: branchPos.left }}
|
|
585
|
-
className="z-[9999] min-w-[120px] rounded-md border bg-popover p-1 shadow-md"
|
|
586
|
-
onKeyDown={(e) => { if (e.key === "Escape") { ctx.setBranchDropdownNode(null); ctx.setBranchDropdownPos(null); } }}
|
|
587
|
-
onClick={(e) => e.stopPropagation()}
|
|
588
|
-
>
|
|
589
|
-
{branchLoading ? (
|
|
590
|
-
<div className="flex justify-center py-2">
|
|
591
|
-
<Loader2 className="h-3.5 w-3.5 animate-spin text-muted-foreground" />
|
|
592
|
-
</div>
|
|
593
|
-
) : branches.length === 0 ? (
|
|
594
|
-
<p className="px-2 py-1 text-[10px] text-muted-foreground">No branches</p>
|
|
595
|
-
) : (
|
|
596
|
-
branches.map((b) => {
|
|
597
|
-
const parentDir = node.path.includes("/")
|
|
598
|
-
? node.path.substring(0, node.path.lastIndexOf("/"))
|
|
599
|
-
: "";
|
|
600
|
-
return (
|
|
601
|
-
<button
|
|
602
|
-
key={b.name}
|
|
603
|
-
type="button"
|
|
604
|
-
disabled={checkingOut !== null || b.current}
|
|
605
|
-
className={cn(
|
|
606
|
-
"flex w-full items-center gap-1.5 rounded px-2 py-1 text-left text-[11px] hover:bg-muted",
|
|
607
|
-
b.current && "font-semibold text-foreground",
|
|
608
|
-
!b.current && "text-muted-foreground",
|
|
609
|
-
)}
|
|
610
|
-
onClick={(e) => {
|
|
611
|
-
e.stopPropagation();
|
|
612
|
-
if (!b.current) ctx.handleCheckout(node.path, b.name, parentDir);
|
|
613
|
-
}}
|
|
614
|
-
>
|
|
615
|
-
{b.current ? <Check className="h-3 w-3 shrink-0" /> : <span className="w-3 shrink-0" />}
|
|
616
|
-
{checkingOut === b.name ? <Loader2 className="h-3 w-3 animate-spin" /> : b.name}
|
|
617
|
-
</button>
|
|
618
|
-
);
|
|
619
|
-
})
|
|
620
|
-
)}
|
|
621
|
-
</div>,
|
|
622
|
-
document.body,
|
|
623
|
-
)}
|
|
624
|
-
</span>
|
|
625
|
-
)}
|
|
626
|
-
|
|
627
|
-
{/* Agent presence dot */}
|
|
628
|
-
{isAgentActive && (
|
|
629
|
-
<span
|
|
630
|
-
className="ml-0.5 h-1.5 w-1.5 rounded-full bg-emerald-400 shrink-0 animate-pulse"
|
|
631
|
-
title="Agent recently active"
|
|
632
|
-
/>
|
|
633
|
-
)}
|
|
634
|
-
|
|
635
|
-
<div
|
|
636
|
-
className="hover-reveal flex max-w-0 shrink-0 items-center overflow-hidden opacity-0 transition-all duration-150 group-hover:max-w-7 group-hover:opacity-100 focus-within:max-w-7 focus-within:opacity-100"
|
|
637
|
-
onClick={(e) => e.stopPropagation()}
|
|
638
|
-
onKeyDown={(e) => e.stopPropagation()}
|
|
639
|
-
>
|
|
640
|
-
<DropdownMenu>
|
|
641
|
-
<DropdownMenuTrigger asChild>
|
|
642
|
-
<Button
|
|
643
|
-
size="sm"
|
|
644
|
-
variant="ghost"
|
|
645
|
-
className="h-6 w-6 p-0 text-muted-foreground hover:text-foreground"
|
|
646
|
-
title="File actions"
|
|
647
|
-
>
|
|
648
|
-
<MoreHorizontal className="h-3.5 w-3.5" />
|
|
649
|
-
</Button>
|
|
650
|
-
</DropdownMenuTrigger>
|
|
651
|
-
<DropdownMenuContent align="end" className="w-48">
|
|
652
|
-
<DropdownMenuItem onClick={() => ctx.copyPath(node.path)}>
|
|
653
|
-
<Copy className="mr-2 h-3.5 w-3.5" />
|
|
654
|
-
Copy path
|
|
655
|
-
</DropdownMenuItem>
|
|
656
|
-
{isMarkdown(node.name) && (
|
|
657
|
-
<DropdownMenuItem onClick={() => ctx.copyWikiLink(node.name)}>
|
|
658
|
-
<FileText className="mr-2 h-3.5 w-3.5" />
|
|
659
|
-
Copy wiki link
|
|
660
|
-
</DropdownMenuItem>
|
|
661
|
-
)}
|
|
662
|
-
<DropdownMenuItem onClick={() => ctx.copyUrl(node.path)}>
|
|
663
|
-
<Link className="mr-2 h-3.5 w-3.5" />
|
|
664
|
-
Copy URL
|
|
665
|
-
</DropdownMenuItem>
|
|
666
|
-
{node.type === "file" && isText(node.name) && (
|
|
667
|
-
<>
|
|
668
|
-
<DropdownMenuItem onClick={() => ctx.copyRawContent(node.path)}>
|
|
669
|
-
<FileText className="mr-2 h-3.5 w-3.5" />
|
|
670
|
-
Copy raw content
|
|
671
|
-
</DropdownMenuItem>
|
|
672
|
-
<DropdownMenuItem onClick={() => ctx.copyFormattedContent(node.path, node.name)}>
|
|
673
|
-
<FileText className="mr-2 h-3.5 w-3.5" />
|
|
674
|
-
Copy formatted content
|
|
675
|
-
</DropdownMenuItem>
|
|
676
|
-
</>
|
|
677
|
-
)}
|
|
678
|
-
<DropdownMenuSeparator />
|
|
679
|
-
{node.type === "dir" && (
|
|
680
|
-
<>
|
|
681
|
-
<DropdownMenuItem
|
|
682
|
-
onClick={async () => {
|
|
683
|
-
if (!node.expanded) ctx.toggleFolder(node);
|
|
684
|
-
ctx.setNewFileParent(node.path);
|
|
685
|
-
ctx.setNewFileName("");
|
|
686
|
-
ctx.setFileCreateError(null);
|
|
687
|
-
}}
|
|
688
|
-
>
|
|
689
|
-
<FilePlus className="mr-2 h-3.5 w-3.5" />
|
|
690
|
-
New file here
|
|
691
|
-
</DropdownMenuItem>
|
|
692
|
-
<DropdownMenuItem onClick={() => ctx.triggerUpload(node.path)}>
|
|
693
|
-
<Upload className="mr-2 h-3.5 w-3.5" />
|
|
694
|
-
Upload here
|
|
695
|
-
</DropdownMenuItem>
|
|
696
|
-
<DropdownMenuItem
|
|
697
|
-
onClick={() => {
|
|
698
|
-
ctx.setNewFolderParent(node.path);
|
|
699
|
-
ctx.setNewFolderName("");
|
|
700
|
-
ctx.setFolderError(null);
|
|
701
|
-
}}
|
|
702
|
-
>
|
|
703
|
-
<FolderPlus className="mr-2 h-3.5 w-3.5" />
|
|
704
|
-
New subfolder
|
|
705
|
-
</DropdownMenuItem>
|
|
706
|
-
<DropdownMenuSeparator />
|
|
707
|
-
</>
|
|
708
|
-
)}
|
|
709
|
-
<DropdownMenuItem onClick={() => ctx.handleDownload(node)}>
|
|
710
|
-
<Download className="mr-2 h-3.5 w-3.5" />
|
|
711
|
-
{node.type === "file" ? "Download" : "Download as zip"}
|
|
712
|
-
</DropdownMenuItem>
|
|
713
|
-
<DropdownMenuItem
|
|
714
|
-
onClick={() => ctx.togglePin(node, activeWorkspaceId)}
|
|
715
|
-
>
|
|
716
|
-
<Star className={cn("mr-2 h-3.5 w-3.5", isPinned && "fill-current text-amber-400")} />
|
|
717
|
-
{isPinned ? "Unpin" : "Pin to top"}
|
|
718
|
-
</DropdownMenuItem>
|
|
719
|
-
<DropdownMenuSeparator />
|
|
720
|
-
<DropdownMenuItem
|
|
721
|
-
className="text-destructive focus:text-destructive"
|
|
722
|
-
onClick={() => {
|
|
723
|
-
ctx.setDeletingPath(node.path);
|
|
724
|
-
ctx.setDeletingIsDir(node.type !== "file");
|
|
725
|
-
}}
|
|
726
|
-
>
|
|
727
|
-
<Trash2 className="mr-2 h-3.5 w-3.5" />
|
|
728
|
-
Delete
|
|
729
|
-
</DropdownMenuItem>
|
|
730
|
-
</DropdownMenuContent>
|
|
731
|
-
</DropdownMenu>
|
|
732
|
-
</div>
|
|
733
|
-
</div>
|
|
734
|
-
</ContextMenuTrigger>
|
|
735
|
-
<ContextMenuContent>
|
|
736
|
-
<ContextMenuItem onSelect={() => ctx.copyPath(node.path)}>
|
|
737
|
-
Copy path
|
|
738
|
-
</ContextMenuItem>
|
|
739
|
-
{isMarkdown(node.name) && (
|
|
740
|
-
<ContextMenuItem onSelect={() => ctx.copyWikiLink(node.name)}>
|
|
741
|
-
Copy wiki link
|
|
742
|
-
</ContextMenuItem>
|
|
743
|
-
)}
|
|
744
|
-
<ContextMenuSeparator />
|
|
745
|
-
<ContextMenuItem onSelect={() => ctx.copyUrl(node.path)}>
|
|
746
|
-
Copy URL
|
|
747
|
-
</ContextMenuItem>
|
|
748
|
-
{node.type === "file" && isText(node.name) && (
|
|
749
|
-
<>
|
|
750
|
-
<ContextMenuSeparator />
|
|
751
|
-
<ContextMenuItem onSelect={() => ctx.copyRawContent(node.path)}>
|
|
752
|
-
Copy raw content
|
|
753
|
-
</ContextMenuItem>
|
|
754
|
-
<ContextMenuItem onSelect={() => ctx.copyFormattedContent(node.path, node.name)}>
|
|
755
|
-
Copy formatted content
|
|
756
|
-
</ContextMenuItem>
|
|
757
|
-
</>
|
|
758
|
-
)}
|
|
759
|
-
</ContextMenuContent>
|
|
760
|
-
</ContextMenu>
|
|
761
|
-
);
|
|
762
|
-
});
|
|
763
|
-
|
|
764
|
-
const FileTree = memo(function FileTree(p: FileTreeProps) {
|
|
765
|
-
const {
|
|
766
|
-
ctx,
|
|
767
|
-
openPath,
|
|
768
|
-
dragOverPath,
|
|
769
|
-
branchDropdownNode,
|
|
770
|
-
branchDropdownPos,
|
|
771
|
-
nodeBranches,
|
|
772
|
-
branchesLoading,
|
|
773
|
-
checkingOutBranch,
|
|
774
|
-
pullingRepo,
|
|
775
|
-
activePaths,
|
|
776
|
-
pins,
|
|
777
|
-
isMobile,
|
|
778
|
-
activeWorkspaceId,
|
|
779
|
-
newFileParent,
|
|
780
|
-
newFileName,
|
|
781
|
-
fileCreateError,
|
|
782
|
-
newFolderParent,
|
|
783
|
-
newFolderName,
|
|
784
|
-
folderError,
|
|
785
|
-
sidebarScrollRef,
|
|
786
|
-
} = p;
|
|
787
|
-
|
|
788
|
-
const showHidden = useShowHiddenStore((s) => s.showHidden);
|
|
789
|
-
|
|
790
|
-
// Hover-intent prefetch: a single shared timer so only the row the pointer
|
|
791
|
-
// settles on (>120ms) is prefetched — passing the cursor over rows doesn't
|
|
792
|
-
// fire a request per row. Stable identities so TreeRowView's memo holds.
|
|
793
|
-
const hoverTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
794
|
-
const onHoverEnter = useCallback((node: TreeNode) => {
|
|
795
|
-
const isCollapsedDir =
|
|
796
|
-
(node.type === "dir" || node.type === "app" || node.type === "node-app") &&
|
|
797
|
-
!node.expanded &&
|
|
798
|
-
node.children === undefined;
|
|
799
|
-
if (node.type !== "file" && !isCollapsedDir) return;
|
|
800
|
-
if (hoverTimer.current) clearTimeout(hoverTimer.current);
|
|
801
|
-
hoverTimer.current = setTimeout(() => ctx.prefetch(node), 120);
|
|
802
|
-
}, [ctx]);
|
|
803
|
-
const onHoverLeave = useCallback(() => {
|
|
804
|
-
if (hoverTimer.current) { clearTimeout(hoverTimer.current); hoverTimer.current = null; }
|
|
805
|
-
}, []);
|
|
806
|
-
|
|
807
|
-
// Flatten the visible tree into a list. Removes the recursion that blocks
|
|
808
|
-
// per-row memo and lets create-inputs / empty markers be plain siblings.
|
|
809
|
-
// Recomputed only when the tree shape or which folder is mid-create changes.
|
|
810
|
-
const flat = useMemo(() => {
|
|
811
|
-
const out: Array<{ kind: "row" | "newfolder" | "newfile" | "empty"; node: TreeNode; depth: number }> = [];
|
|
812
|
-
const walk = (nodes: TreeNode[], depth: number) => {
|
|
813
|
-
for (const node of nodes) {
|
|
814
|
-
if (!showHidden && node.name.startsWith(".")) continue;
|
|
815
|
-
out.push({ kind: "row", node, depth });
|
|
816
|
-
if (node.type === "dir") {
|
|
817
|
-
if (newFolderParent === node.path) out.push({ kind: "newfolder", node, depth });
|
|
818
|
-
if (newFileParent === node.path) out.push({ kind: "newfile", node, depth });
|
|
819
|
-
}
|
|
820
|
-
if ((node.type === "dir" || node.type === "app" || node.type === "node-app") && node.expanded) {
|
|
821
|
-
if (node.children && node.children.length > 0) walk(node.children, depth + 1);
|
|
822
|
-
else if (node.children?.length === 0) out.push({ kind: "empty", node, depth });
|
|
823
|
-
}
|
|
824
|
-
}
|
|
825
|
-
};
|
|
826
|
-
walk(p.nodes, 0);
|
|
827
|
-
return out;
|
|
828
|
-
}, [p.nodes, newFileParent, newFolderParent, showHidden]);
|
|
829
|
-
|
|
830
|
-
return (
|
|
831
|
-
<>
|
|
832
|
-
{flat.map((item) => {
|
|
833
|
-
const { node, depth } = item;
|
|
834
|
-
if (item.kind === "row") {
|
|
835
|
-
const branchOpen = branchDropdownNode === node.path;
|
|
836
|
-
return (
|
|
837
|
-
<TreeRowView
|
|
838
|
-
key={node.path}
|
|
839
|
-
node={node}
|
|
840
|
-
depth={depth}
|
|
841
|
-
ctx={ctx}
|
|
842
|
-
isMobile={isMobile}
|
|
843
|
-
activeWorkspaceId={activeWorkspaceId}
|
|
844
|
-
sidebarScrollRef={sidebarScrollRef}
|
|
845
|
-
isActive={openPath === node.path}
|
|
846
|
-
isDragOver={dragOverPath === node.path}
|
|
847
|
-
isPinned={pins.some((pin) => pin.path === node.path)}
|
|
848
|
-
isAgentActive={activePaths.has(node.path)}
|
|
849
|
-
isPulling={pullingRepo === node.path}
|
|
850
|
-
branchOpen={branchOpen}
|
|
851
|
-
branchPos={branchOpen ? branchDropdownPos : null}
|
|
852
|
-
branches={node.git ? (nodeBranches[node.path] ?? []) : []}
|
|
853
|
-
branchLoading={node.git ? !!branchesLoading[node.path] : false}
|
|
854
|
-
checkingOut={branchOpen ? checkingOutBranch : null}
|
|
855
|
-
onHoverEnter={onHoverEnter}
|
|
856
|
-
onHoverLeave={onHoverLeave}
|
|
857
|
-
/>
|
|
858
|
-
);
|
|
859
|
-
}
|
|
860
|
-
if (item.kind === "newfolder") {
|
|
861
|
-
return (
|
|
862
|
-
<div
|
|
863
|
-
key={`nf-${node.path}`}
|
|
864
|
-
className="flex items-center gap-1.5 px-2 py-1"
|
|
865
|
-
style={{ paddingLeft: `${(depth + 1) * 14 + 8}px`, ...ROW_CV }}
|
|
866
|
-
>
|
|
867
|
-
<span className="w-3.5 shrink-0" />
|
|
868
|
-
<Folder className="h-4 w-4 shrink-0 text-warning" />
|
|
869
|
-
<input
|
|
870
|
-
className="flex-1 bg-transparent text-sm outline-none border-b border-border min-w-0"
|
|
871
|
-
placeholder="Folder name"
|
|
872
|
-
value={newFolderName}
|
|
873
|
-
onChange={(e) => ctx.setNewFolderName(e.target.value)}
|
|
874
|
-
onKeyDown={(e) => {
|
|
875
|
-
if (e.key === "Enter") ctx.handleCreateFolder();
|
|
876
|
-
if (e.key === "Escape") { ctx.setNewFolderParent(null); ctx.setNewFolderName(""); }
|
|
877
|
-
}}
|
|
878
|
-
/>
|
|
879
|
-
{folderError && <span className="text-xs text-destructive">{folderError}</span>}
|
|
880
|
-
<Button size="sm" variant="ghost" className="h-6 w-6 p-0" onClick={ctx.handleCreateFolder}>
|
|
881
|
-
<Check className="h-3 w-3" />
|
|
882
|
-
</Button>
|
|
883
|
-
<Button size="sm" variant="ghost" className="h-6 w-6 p-0" onClick={() => { ctx.setNewFolderParent(null); ctx.setNewFolderName(""); }}>
|
|
884
|
-
<X className="h-3 w-3" />
|
|
885
|
-
</Button>
|
|
886
|
-
</div>
|
|
887
|
-
);
|
|
888
|
-
}
|
|
889
|
-
if (item.kind === "newfile") {
|
|
890
|
-
return (
|
|
891
|
-
<div
|
|
892
|
-
key={`ff-${node.path}`}
|
|
893
|
-
className="flex items-center gap-1.5 px-2 py-1"
|
|
894
|
-
style={{ paddingLeft: `${(depth + 1) * 14 + 8}px`, ...ROW_CV }}
|
|
895
|
-
>
|
|
896
|
-
<span className="w-3.5 shrink-0" />
|
|
897
|
-
<FileText className="h-4 w-4 shrink-0 text-accent" />
|
|
898
|
-
<input
|
|
899
|
-
autoFocus
|
|
900
|
-
className="flex-1 bg-transparent text-sm outline-none border-b border-border min-w-0"
|
|
901
|
-
placeholder="filename (default .md)"
|
|
902
|
-
value={newFileName}
|
|
903
|
-
onChange={(e) => ctx.setNewFileName(e.target.value)}
|
|
904
|
-
onKeyDown={(e) => {
|
|
905
|
-
if (e.key === "Enter") ctx.handleCreateFile();
|
|
906
|
-
if (e.key === "Escape") { ctx.setNewFileParent(null); ctx.setNewFileName(""); }
|
|
907
|
-
}}
|
|
908
|
-
/>
|
|
909
|
-
{fileCreateError && <span className="text-xs text-destructive">{fileCreateError}</span>}
|
|
910
|
-
<Button size="sm" variant="ghost" className="h-6 w-6 p-0" onClick={ctx.handleCreateFile}>
|
|
911
|
-
<Check className="h-3 w-3" />
|
|
912
|
-
</Button>
|
|
913
|
-
<Button size="sm" variant="ghost" className="h-6 w-6 p-0" onClick={() => { ctx.setNewFileParent(null); ctx.setNewFileName(""); }}>
|
|
914
|
-
<X className="h-3 w-3" />
|
|
915
|
-
</Button>
|
|
916
|
-
</div>
|
|
917
|
-
);
|
|
918
|
-
}
|
|
919
|
-
// empty
|
|
920
|
-
return (
|
|
921
|
-
<div
|
|
922
|
-
key={`empty-${node.path}`}
|
|
923
|
-
className="text-xs text-muted-foreground/50 py-0.5"
|
|
924
|
-
style={{ paddingLeft: `${(depth + 1) * 14 + 8 + 14 + 6 + 16 + 6}px`, ...ROW_CV }}
|
|
925
|
-
>
|
|
926
|
-
Empty
|
|
927
|
-
</div>
|
|
928
|
-
);
|
|
929
|
-
})}
|
|
930
|
-
</>
|
|
931
|
-
);
|
|
932
|
-
});
|
|
933
|
-
|
|
934
|
-
export default function Page() {
|
|
935
|
-
const slugsLoadedAt = useWikiSlugsStore((s) => s.loadedAt);
|
|
936
|
-
useEffect(() => {
|
|
937
|
-
void useWikiSlugsStore.getState().load();
|
|
938
|
-
}, []);
|
|
939
|
-
void slugsLoadedAt;
|
|
940
|
-
|
|
941
|
-
// null = checking, false = not set, true = ready
|
|
942
|
-
const [rootConfigured, setRootConfigured] = useState<boolean | null>(null);
|
|
943
|
-
const [rootPath, setRootPath] = useState<string | null>(null);
|
|
944
|
-
// Workspace state
|
|
945
|
-
const [activeWorkspaceId, setActiveWorkspaceId] = useState<string | null>(() =>
|
|
946
|
-
typeof window !== "undefined" ? getActiveWorkspaceId() : null
|
|
947
|
-
);
|
|
948
|
-
const [workspaces, setWorkspaces] = useState<Array<{id:string;name:string;rootDir:string;lastOpenedAt?:string;createdAt:string;readOnly?:boolean;git?:{remoteUrl:string;branch?:string;username?:string;lastPulledAt?:string;lastSha?:string;lastError?:string}}>>([]);
|
|
949
|
-
const [isWsAdmin, setIsWsAdmin] = useState(false);
|
|
950
|
-
const [addingWorkspace, setAddingWorkspace] = useState(false);
|
|
951
|
-
const [deletingWorkspaceId, setDeletingWorkspaceId] = useState<string | null>(null);
|
|
952
|
-
const [refreshingWsId, setRefreshingWsId] = useState<string | null>(null);
|
|
953
|
-
|
|
954
|
-
const loadWorkspaces = useCallback(async () => {
|
|
955
|
-
try {
|
|
956
|
-
const res = await fetch("/api/system/workspaces");
|
|
957
|
-
if (!res.ok) throw new Error("Failed");
|
|
958
|
-
const d: { workspaces: Array<{id:string;name:string;rootDir:string;lastOpenedAt?:string;createdAt:string;readOnly?:boolean;git?:{remoteUrl:string;branch?:string;username?:string;lastPulledAt?:string;lastSha?:string;lastError?:string}}>; isAdmin: boolean } = await res.json();
|
|
959
|
-
setWorkspaces(d.workspaces);
|
|
960
|
-
setIsWsAdmin(d.isAdmin);
|
|
961
|
-
if (d.workspaces.length > 0) {
|
|
962
|
-
setRootConfigured(true);
|
|
963
|
-
const urlWsId = new URLSearchParams(window.location.search).get("ws");
|
|
964
|
-
const inList = urlWsId ? d.workspaces.find((w) => w.id === urlWsId) : null;
|
|
965
|
-
const active = inList ?? [...d.workspaces].sort(
|
|
966
|
-
(a, b) => (b.lastOpenedAt ?? b.createdAt).localeCompare(a.lastOpenedAt ?? a.createdAt)
|
|
967
|
-
)[0];
|
|
968
|
-
setActiveWorkspaceId(active.id);
|
|
969
|
-
setRootPath(active.rootDir);
|
|
970
|
-
if (!inList) {
|
|
971
|
-
const u = new URL(location.href);
|
|
972
|
-
u.searchParams.set("ws", active.id);
|
|
973
|
-
history.replaceState(null, "", u.toString());
|
|
974
|
-
}
|
|
975
|
-
} else {
|
|
976
|
-
setRootConfigured(false);
|
|
977
|
-
}
|
|
978
|
-
} catch {
|
|
979
|
-
setRootConfigured(false);
|
|
980
|
-
}
|
|
981
|
-
}, []);
|
|
982
|
-
|
|
983
|
-
useEffect(() => {
|
|
984
|
-
void loadWorkspaces();
|
|
985
|
-
}, [loadWorkspaces]);
|
|
986
|
-
|
|
987
|
-
const editorCurrentPath = useEditorStore((s) => s.currentPath);
|
|
988
|
-
|
|
989
|
-
// Recent files
|
|
990
|
-
const recents = useRecentStore((s) => s.recents);
|
|
991
|
-
const [pinnedCollapsed, setPinnedCollapsed] = useState(false);
|
|
992
|
-
const [recentCollapsed, setRecentCollapsed] = useState(true);
|
|
993
|
-
|
|
994
|
-
// Pins
|
|
995
|
-
const pins = usePinStore((s) => s.pins);
|
|
996
|
-
|
|
997
|
-
// Sidebar scroll container ref (for keyboard nav)
|
|
998
|
-
const sidebarScrollRef = useRef<HTMLDivElement>(null);
|
|
999
|
-
|
|
1000
|
-
// Agent presence: paths touched by agents in last 60s
|
|
1001
|
-
const activity = useAIPanelStore((s) => s.activity);
|
|
1002
|
-
const activePaths = useMemo(() => {
|
|
1003
|
-
const cutoff = new Date(Date.now() - 60 * 1000).toISOString();
|
|
1004
|
-
const paths = new Set<string>();
|
|
1005
|
-
for (const ev of activity) {
|
|
1006
|
-
// Only agent-authored events count as "agent presence".
|
|
1007
|
-
if (ev.at >= cutoff && ev.path && ev.by?.startsWith("ai:"))
|
|
1008
|
-
paths.add(ev.path);
|
|
1009
|
-
}
|
|
1010
|
-
return paths;
|
|
1011
|
-
}, [activity]);
|
|
1012
|
-
|
|
1013
|
-
// Poll activity for presence indicator
|
|
1014
|
-
useEffect(() => {
|
|
1015
|
-
const load = () => { void useAIPanelStore.getState().loadActivity(); };
|
|
1016
|
-
load();
|
|
1017
|
-
const id = setInterval(load, 10_000);
|
|
1018
|
-
return () => clearInterval(id);
|
|
1019
|
-
}, []);
|
|
1020
|
-
|
|
1021
|
-
// Reload recents + pins when workspace changes
|
|
1022
|
-
useEffect(() => {
|
|
1023
|
-
useRecentStore.getState().loadForWorkspace(activeWorkspaceId);
|
|
1024
|
-
usePinStore.getState().loadForWorkspace(activeWorkspaceId);
|
|
1025
|
-
}, [activeWorkspaceId]);
|
|
1026
|
-
|
|
1027
|
-
// Speculative warm-up: at idle, prefetch the markdown files the user is most
|
|
1028
|
-
// likely to open next — pins and recents (the top of the sidebar). prefetchPage
|
|
1029
|
-
// dedups, so this is a no-op for already-cached pages. Bounded to keep it cheap.
|
|
1030
|
-
useEffect(() => {
|
|
1031
|
-
const paths = [
|
|
1032
|
-
...pins.map((p) => p.path),
|
|
1033
|
-
...recents.slice(0, 8).map((r) => r.path),
|
|
1034
|
-
]
|
|
1035
|
-
.filter((p, i, arr) => isMarkdown(p) && arr.indexOf(p) === i)
|
|
1036
|
-
.slice(0, 12);
|
|
1037
|
-
if (paths.length === 0) return;
|
|
1038
|
-
const ric: (cb: () => void) => number =
|
|
1039
|
-
typeof window !== "undefined" && "requestIdleCallback" in window
|
|
1040
|
-
? (cb) => (window as unknown as { requestIdleCallback: (c: () => void) => number }).requestIdleCallback(cb)
|
|
1041
|
-
: (cb) => window.setTimeout(cb, 400);
|
|
1042
|
-
const id = ric(() => { for (const p of paths) prefetchPage(p); });
|
|
1043
|
-
return () => {
|
|
1044
|
-
if (typeof window !== "undefined" && "cancelIdleCallback" in window) {
|
|
1045
|
-
(window as unknown as { cancelIdleCallback: (h: number) => void }).cancelIdleCallback(id);
|
|
1046
|
-
} else {
|
|
1047
|
-
clearTimeout(id);
|
|
1048
|
-
}
|
|
1049
|
-
};
|
|
1050
|
-
}, [pins, recents]);
|
|
1051
|
-
|
|
1052
|
-
// Path captured from the URL at first render, before any effect can clear it.
|
|
1053
|
-
// Restore reads from this ref (never the live URL) so URL sync can't break it.
|
|
1054
|
-
const initialUrlPathRef = useRef<string | null>(
|
|
1055
|
-
typeof window !== "undefined"
|
|
1056
|
-
? new URLSearchParams(window.location.search).get("path")
|
|
1057
|
-
: null,
|
|
1058
|
-
);
|
|
1059
|
-
const didRestoreRef = useRef(false);
|
|
1060
|
-
|
|
1061
|
-
const [roots, setRoots] = useState<TreeNode[]>([]);
|
|
1062
|
-
const [rootLoaded, setRootLoaded] = useState(false);
|
|
1063
|
-
const [rootLoading, setRootLoading] = useState(false);
|
|
1064
|
-
const rootLoadingRef = useRef(false);
|
|
1065
|
-
const [refreshingTree, setRefreshingTree] = useState(false);
|
|
1066
|
-
|
|
1067
|
-
const [uploading, setUploading] = useState(false);
|
|
1068
|
-
const [uploadError, setUploadError] = useState<string | null>(null);
|
|
1069
|
-
const fileInputRef = useRef<HTMLInputElement>(null);
|
|
1070
|
-
const uploadDirRef = useRef<string>("");
|
|
1071
|
-
|
|
1072
|
-
const [newFolderParent, setNewFolderParent] = useState<string | null>(null);
|
|
1073
|
-
const [newFolderName, setNewFolderName] = useState("");
|
|
1074
|
-
const [folderError, setFolderError] = useState<string | null>(null);
|
|
1075
|
-
|
|
1076
|
-
const [newFileParent, setNewFileParent] = useState<string | null>(null);
|
|
1077
|
-
const [newFileName, setNewFileName] = useState("");
|
|
1078
|
-
const [fileCreateError, setFileCreateError] = useState<string | null>(null);
|
|
1079
|
-
|
|
1080
|
-
const [deletingPath, setDeletingPath] = useState<string | null>(null);
|
|
1081
|
-
const [deletingIsDir, setDeletingIsDir] = useState(false);
|
|
1082
|
-
|
|
1083
|
-
const [openFile, setOpenFile] = useState<{
|
|
1084
|
-
path: string;
|
|
1085
|
-
name: string;
|
|
1086
|
-
nodeType: "file" | "app" | "node-app";
|
|
1087
|
-
size?: number;
|
|
1088
|
-
} | null>(null);
|
|
1089
|
-
// Path the user clicked "open anyway" for. Keyed by path so confirming one
|
|
1090
|
-
// large file does not open the next.
|
|
1091
|
-
const [gateBypassPath, setGateBypassPath] = useState<string | null>(null);
|
|
1092
|
-
const [appFullscreen, setAppFullscreen] = useState(false);
|
|
1093
|
-
const [appKey, setAppKey] = useState(0);
|
|
1094
|
-
const isMobile = useIsMobile();
|
|
1095
|
-
const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
|
|
1096
|
-
const sidebarWidth = useSidebarWidthStore((s) => s.width);
|
|
1097
|
-
const showHidden = useShowHiddenStore((s) => s.showHidden);
|
|
1098
|
-
const setSidebarWidth = useSidebarWidthStore((s) => s.setWidth);
|
|
1099
|
-
// Mobile: when the viewport is mobile, the sidebar defaults to closed.
|
|
1100
|
-
// Re-runs when isMobile flips (orientation change, devtools resize).
|
|
1101
|
-
useEffect(() => {
|
|
1102
|
-
if (isMobile) setSidebarCollapsed(true);
|
|
1103
|
-
}, [isMobile]);
|
|
1104
|
-
const [sidebarResizing, setSidebarResizing] = useState(false);
|
|
1105
|
-
const [settingsOpen, setSettingsOpen] = useState(false);
|
|
1106
|
-
const [shareDialogOpen, setShareDialogOpen] = useState(false);
|
|
1107
|
-
const [fileContent, setFileContent] = useState<string | null>(null);
|
|
1108
|
-
const [fileRevision, setFileRevision] = useState(0);
|
|
1109
|
-
const [fileLoading, setFileLoading] = useState(false);
|
|
1110
|
-
const [editing, setEditing] = useState(false);
|
|
1111
|
-
const [editContent, setEditContent] = useState("");
|
|
1112
|
-
const [saving, setSaving] = useState(false);
|
|
1113
|
-
const [saveError, setSaveError] = useState<string | null>(null);
|
|
1114
|
-
|
|
1115
|
-
// Drag state
|
|
1116
|
-
const dragNodeRef = useRef<TreeNode | null>(null);
|
|
1117
|
-
const [dragOverPath, setDragOverPath] = useState<string | null>(null);
|
|
1118
|
-
|
|
1119
|
-
// Refs for watcher handler (avoids stale closures in the SSE effect)
|
|
1120
|
-
const openFileRef = useRef<typeof openFile>(null);
|
|
1121
|
-
const editingRef = useRef(false);
|
|
1122
|
-
const refreshViewerRef = useRef<() => Promise<void>>(async () => {});
|
|
1123
|
-
|
|
1124
|
-
useEffect(() => {
|
|
1125
|
-
if (rootLoaded || rootLoadingRef.current) return;
|
|
1126
|
-
rootLoadingRef.current = true;
|
|
1127
|
-
setRootLoading(true);
|
|
1128
|
-
|
|
1129
|
-
fetchDir("")
|
|
1130
|
-
.then((nodes) => {
|
|
1131
|
-
setRoots(nodes);
|
|
1132
|
-
setRootLoaded(true);
|
|
1133
|
-
setRootLoading(false);
|
|
1134
|
-
})
|
|
1135
|
-
.catch(() => {
|
|
1136
|
-
rootLoadingRef.current = false;
|
|
1137
|
-
setRootLoading(false);
|
|
1138
|
-
});
|
|
1139
|
-
}, [rootLoaded]);
|
|
1140
|
-
|
|
1141
|
-
// Expand every ancestor folder of `p` so the file is visible in the tree.
|
|
1142
|
-
// Best-effort: requires the root nodes to already be loaded.
|
|
1143
|
-
const revealPath = useCallback(async (p: string) => {
|
|
1144
|
-
const parts = p.split("/");
|
|
1145
|
-
if (parts.length <= 1) return;
|
|
1146
|
-
|
|
1147
|
-
// Fetch every ancestor's children first. We must NOT apply them with one
|
|
1148
|
-
// setRoots per level: those updaters run against the same stale `prev`
|
|
1149
|
-
// snapshot, so a nested prefix isn't in the tree yet and updateNodes
|
|
1150
|
-
// silently no-ops (only the top level ever matched). Collect all levels,
|
|
1151
|
-
// then splice them in a single pass so each parent's children exist before
|
|
1152
|
-
// the next level is inserted.
|
|
1153
|
-
const levels: { prefix: string; children: TreeNode[] }[] = [];
|
|
1154
|
-
let prefix = "";
|
|
1155
|
-
for (let i = 0; i < parts.length - 1; i++) {
|
|
1156
|
-
prefix = prefix ? `${prefix}/${parts[i]}` : parts[i];
|
|
1157
|
-
levels.push({ prefix, children: await fetchDir(prefix) });
|
|
1158
|
-
}
|
|
1159
|
-
|
|
1160
|
-
setRoots((prev) => {
|
|
1161
|
-
let next = prev;
|
|
1162
|
-
for (const { prefix: pfx, children } of levels) {
|
|
1163
|
-
next = updateNodes(next, pfx, (n) => ({
|
|
1164
|
-
...n,
|
|
1165
|
-
children,
|
|
1166
|
-
expanded: true,
|
|
1167
|
-
}));
|
|
1168
|
-
}
|
|
1169
|
-
return next;
|
|
1170
|
-
});
|
|
1171
|
-
}, []);
|
|
1172
|
-
|
|
1173
|
-
const reloadDir = useCallback(async (dir: string) => {
|
|
1174
|
-
const fresh = await fetchDir(dir);
|
|
1175
|
-
if (dir === "") {
|
|
1176
|
-
// Merge: keep expanded state + loaded children for nodes that still
|
|
1177
|
-
// exist. A blind setRoots(fresh) would collapse the tree and wipe any
|
|
1178
|
-
// expansion done by revealPath on reload (watcher fires this often).
|
|
1179
|
-
setRoots((prev) => {
|
|
1180
|
-
const prevByPath = new Map(prev.map((n) => [n.path, n]));
|
|
1181
|
-
return fresh.map((n) => {
|
|
1182
|
-
const old = prevByPath.get(n.path);
|
|
1183
|
-
if (old && (old.type === "dir" || old.type === "app")) {
|
|
1184
|
-
return {
|
|
1185
|
-
...n,
|
|
1186
|
-
expanded: old.expanded,
|
|
1187
|
-
children: old.children,
|
|
1188
|
-
};
|
|
1189
|
-
}
|
|
1190
|
-
return n;
|
|
1191
|
-
});
|
|
1192
|
-
});
|
|
1193
|
-
} else {
|
|
1194
|
-
setRoots((prev) =>
|
|
1195
|
-
updateNodes(prev, dir, (n) => ({
|
|
1196
|
-
...n,
|
|
1197
|
-
children: fresh,
|
|
1198
|
-
expanded: true,
|
|
1199
|
-
})),
|
|
1200
|
-
);
|
|
1201
|
-
}
|
|
1202
|
-
}, []);
|
|
1203
|
-
|
|
1204
|
-
const [pullingRepo, setPullingRepo] = useState<string | null>(null);
|
|
1205
|
-
|
|
1206
|
-
// Git file info (last author + date for open file)
|
|
1207
|
-
const [gitFileInfo, setGitFileInfo] = useState<{ sha: string; author: string; date: string } | null>(null);
|
|
1208
|
-
|
|
1209
|
-
// History panel
|
|
1210
|
-
const [showHistory, setShowHistory] = useState(false);
|
|
1211
|
-
const [historyCommits, setHistoryCommits] = useState<{ sha: string; shortSha: string; message: string; author: string; date: string }[]>([]);
|
|
1212
|
-
const [historyLoading, setHistoryLoading] = useState(false);
|
|
1213
|
-
const [selectedDiffSha, setSelectedDiffSha] = useState<string | null>(null);
|
|
1214
|
-
const [diffContent, setDiffContent] = useState<string | null>(null);
|
|
1215
|
-
const [diffLoading, setDiffLoading] = useState(false);
|
|
1216
|
-
|
|
1217
|
-
// Branch switcher
|
|
1218
|
-
const [nodeBranches, setNodeBranches] = useState<Record<string, { name: string; current: boolean }[]>>({});
|
|
1219
|
-
const [branchesLoading, setBranchesLoading] = useState<Record<string, boolean>>({});
|
|
1220
|
-
const [checkingOutBranch, setCheckingOutBranch] = useState<string | null>(null);
|
|
1221
|
-
const [branchDropdownNode, setBranchDropdownNode] = useState<string | null>(null);
|
|
1222
|
-
const [branchDropdownPos, setBranchDropdownPos] = useState<{ top: number; left: number } | null>(null);
|
|
1223
|
-
|
|
1224
|
-
const handleGitPull = useCallback(async (nodePath: string, parentDir: string) => {
|
|
1225
|
-
if (pullingRepo) return;
|
|
1226
|
-
setPullingRepo(nodePath);
|
|
1227
|
-
try {
|
|
1228
|
-
const res = await wsFetch("/api/wiki/git-pull", {
|
|
1229
|
-
method: "POST",
|
|
1230
|
-
body: JSON.stringify({ path: nodePath }),
|
|
1231
|
-
});
|
|
1232
|
-
if (!res.ok) {
|
|
1233
|
-
const e: { error?: string; message?: string } = await res.json();
|
|
1234
|
-
showError(e.message ?? e.error ?? "Pull failed");
|
|
1235
|
-
return;
|
|
1236
|
-
}
|
|
1237
|
-
const data: { branch: string; sha: string } = await res.json();
|
|
1238
|
-
showSuccess(`Pulled ${nodePath} (${data.branch} @ ${data.sha.slice(0, 7)})`);
|
|
1239
|
-
await reloadDir(parentDir);
|
|
1240
|
-
} catch {
|
|
1241
|
-
showError("Pull failed");
|
|
1242
|
-
} finally {
|
|
1243
|
-
setPullingRepo(null);
|
|
1244
|
-
}
|
|
1245
|
-
}, [pullingRepo, reloadDir]);
|
|
1246
|
-
|
|
1247
|
-
// Reset history panel when file changes
|
|
1248
|
-
useEffect(() => {
|
|
1249
|
-
setShowHistory(false);
|
|
1250
|
-
setHistoryCommits([]);
|
|
1251
|
-
setSelectedDiffSha(null);
|
|
1252
|
-
setDiffContent(null);
|
|
1253
|
-
}, [openFile?.path]);
|
|
1254
|
-
|
|
1255
|
-
// Fetch git metadata for open file. Keyed on path only: re-fetching on every
|
|
1256
|
-
// openFile object re-ref (unrelated re-renders) wastes a git round-trip.
|
|
1257
|
-
const openPath = openFile?.path;
|
|
1258
|
-
useEffect(() => {
|
|
1259
|
-
if (!openPath) { setGitFileInfo(null); return; }
|
|
1260
|
-
let cancelled = false;
|
|
1261
|
-
// Debounced: rapid navigation shouldn't fire a git round-trip per pass-through.
|
|
1262
|
-
const timer = setTimeout(() => {
|
|
1263
|
-
void (async () => {
|
|
1264
|
-
try {
|
|
1265
|
-
const res = await wsFetch(`/api/wiki/git-file-info?path=${encodeURIComponent(openPath)}`);
|
|
1266
|
-
if (cancelled) return;
|
|
1267
|
-
if (!res.ok) { setGitFileInfo(null); return; }
|
|
1268
|
-
const d: { info: { sha: string; author: string; date: string } | null } = await res.json();
|
|
1269
|
-
if (!cancelled) setGitFileInfo(d.info);
|
|
1270
|
-
} catch { if (!cancelled) setGitFileInfo(null); }
|
|
1271
|
-
})();
|
|
1272
|
-
}, 200);
|
|
1273
|
-
return () => { cancelled = true; clearTimeout(timer); };
|
|
1274
|
-
}, [openPath]);
|
|
1275
|
-
|
|
1276
|
-
const loadHistory = useCallback(async () => {
|
|
1277
|
-
if (!openFile) return;
|
|
1278
|
-
const path = openFile.path;
|
|
1279
|
-
setShowHistory(true);
|
|
1280
|
-
setHistoryLoading(true);
|
|
1281
|
-
setHistoryCommits([]);
|
|
1282
|
-
setSelectedDiffSha(null);
|
|
1283
|
-
setDiffContent(null);
|
|
1284
|
-
try {
|
|
1285
|
-
const res = await wsFetch(`/api/wiki/git-history?path=${encodeURIComponent(path)}`);
|
|
1286
|
-
if (!res.ok) { showError("Could not load history"); return; }
|
|
1287
|
-
const d: { commits: { sha: string; shortSha: string; message: string; author: string; date: string }[] } = await res.json();
|
|
1288
|
-
if (openFile.path === path) setHistoryCommits(d.commits);
|
|
1289
|
-
} catch { showError("Could not load history"); }
|
|
1290
|
-
finally { setHistoryLoading(false); }
|
|
1291
|
-
}, [openFile]);
|
|
1292
|
-
|
|
1293
|
-
const selectDiff = useCallback(async (sha: string) => {
|
|
1294
|
-
if (!openFile) return;
|
|
1295
|
-
if (selectedDiffSha === sha) { setSelectedDiffSha(null); setDiffContent(null); return; }
|
|
1296
|
-
const path = openFile.path;
|
|
1297
|
-
const targetSha = sha;
|
|
1298
|
-
setSelectedDiffSha(sha);
|
|
1299
|
-
setDiffLoading(true);
|
|
1300
|
-
setDiffContent(null);
|
|
1301
|
-
try {
|
|
1302
|
-
const res = await wsFetch(`/api/wiki/git-diff?path=${encodeURIComponent(path)}&sha=${encodeURIComponent(sha)}`);
|
|
1303
|
-
if (!res.ok) { showError("Could not load diff"); return; }
|
|
1304
|
-
const d: { diff: string } = await res.json();
|
|
1305
|
-
if (openFile.path === path && selectedDiffSha === targetSha) setDiffContent(d.diff);
|
|
1306
|
-
} catch { showError("Could not load diff"); }
|
|
1307
|
-
finally { setDiffLoading(false); }
|
|
1308
|
-
}, [openFile, selectedDiffSha]);
|
|
1309
|
-
|
|
1310
|
-
const loadBranches = useCallback(async (nodePath: string) => {
|
|
1311
|
-
if (nodeBranches[nodePath] || branchesLoading[nodePath]) return;
|
|
1312
|
-
let cancelled = false;
|
|
1313
|
-
setBranchesLoading((prev) => ({ ...prev, [nodePath]: true }));
|
|
1314
|
-
try {
|
|
1315
|
-
const res = await wsFetch(`/api/wiki/git-branches?path=${encodeURIComponent(nodePath)}`);
|
|
1316
|
-
if (cancelled) return;
|
|
1317
|
-
if (!res.ok) { showError("Could not load branches"); return; }
|
|
1318
|
-
const d: { branches: { name: string; current: boolean }[] } = await res.json();
|
|
1319
|
-
if (!cancelled) setNodeBranches((prev) => ({ ...prev, [nodePath]: d.branches }));
|
|
1320
|
-
} catch { if (!cancelled) showError("Could not load branches"); }
|
|
1321
|
-
finally { if (!cancelled) setBranchesLoading((prev) => ({ ...prev, [nodePath]: false })); }
|
|
1322
|
-
return () => { cancelled = true; };
|
|
1323
|
-
}, [nodeBranches, branchesLoading]);
|
|
1324
|
-
|
|
1325
|
-
const handleCheckout = useCallback(async (nodePath: string, branch: string, parentDir: string) => {
|
|
1326
|
-
if (checkingOutBranch) return;
|
|
1327
|
-
setCheckingOutBranch(branch);
|
|
1328
|
-
try {
|
|
1329
|
-
const res = await wsFetch("/api/wiki/git-checkout", {
|
|
1330
|
-
method: "POST",
|
|
1331
|
-
body: JSON.stringify({ path: nodePath, branch }),
|
|
1332
|
-
});
|
|
1333
|
-
if (res.status === 409) { showError("Repository has uncommitted changes"); return; }
|
|
1334
|
-
if (!res.ok) { const e: { error?: string } = await res.json(); showError(e.error ?? "Checkout failed"); return; }
|
|
1335
|
-
const d: { branch: string; sha: string } = await res.json();
|
|
1336
|
-
showSuccess(`Switched to ${d.branch}`);
|
|
1337
|
-
// Invalidate cached branches so next open re-fetches
|
|
1338
|
-
setNodeBranches((prev) => { const n = { ...prev }; delete n[nodePath]; return n; });
|
|
1339
|
-
setBranchDropdownNode(null);
|
|
1340
|
-
setBranchDropdownPos(null);
|
|
1341
|
-
await reloadDir(parentDir);
|
|
1342
|
-
} catch { showError("Checkout failed"); }
|
|
1343
|
-
finally { setCheckingOutBranch(null); }
|
|
1344
|
-
}, [checkingOutBranch, reloadDir]);
|
|
1345
|
-
|
|
1346
|
-
// Close branch dropdown on outside click or Escape
|
|
1347
|
-
useEffect(() => {
|
|
1348
|
-
if (!branchDropdownNode) return;
|
|
1349
|
-
const handleClick = () => { setBranchDropdownNode(null); setBranchDropdownPos(null); };
|
|
1350
|
-
const handleKey = (e: KeyboardEvent) => { if (e.key === "Escape") { setBranchDropdownNode(null); setBranchDropdownPos(null); } };
|
|
1351
|
-
document.addEventListener("click", handleClick);
|
|
1352
|
-
document.addEventListener("keydown", handleKey);
|
|
1353
|
-
return () => {
|
|
1354
|
-
document.removeEventListener("click", handleClick);
|
|
1355
|
-
document.removeEventListener("keydown", handleKey);
|
|
1356
|
-
};
|
|
1357
|
-
}, [branchDropdownNode]);
|
|
1358
|
-
|
|
1359
|
-
const collectExpandedPaths = useCallback((nodes: TreeNode[]): string[] => {
|
|
1360
|
-
const paths: string[] = [];
|
|
1361
|
-
for (const n of nodes) {
|
|
1362
|
-
if ((n.type === "dir" || n.type === "app" || n.type === "node-app") && n.expanded && n.children) {
|
|
1363
|
-
paths.push(n.path);
|
|
1364
|
-
paths.push(...collectExpandedPaths(n.children));
|
|
1365
|
-
}
|
|
1366
|
-
}
|
|
1367
|
-
return paths;
|
|
1368
|
-
}, []);
|
|
1369
|
-
|
|
1370
|
-
const refreshTree = useCallback(async () => {
|
|
1371
|
-
setRefreshingTree(true);
|
|
1372
|
-
try {
|
|
1373
|
-
const expandedPaths = collectExpandedPaths(roots);
|
|
1374
|
-
const fresh = await fetchDir("");
|
|
1375
|
-
setRoots(fresh);
|
|
1376
|
-
for (const p of expandedPaths) {
|
|
1377
|
-
const dirFresh = await fetchDir(p);
|
|
1378
|
-
setRoots((prev) =>
|
|
1379
|
-
updateNodes(prev, p, (n) => ({
|
|
1380
|
-
...n,
|
|
1381
|
-
children: dirFresh,
|
|
1382
|
-
expanded: true,
|
|
1383
|
-
})),
|
|
1384
|
-
);
|
|
1385
|
-
}
|
|
1386
|
-
} finally {
|
|
1387
|
-
setRefreshingTree(false);
|
|
1388
|
-
}
|
|
1389
|
-
}, [roots, collectExpandedPaths]);
|
|
1390
|
-
|
|
1391
|
-
const refreshViewer = useCallback(async () => {
|
|
1392
|
-
if (!openFile) return;
|
|
1393
|
-
if (isMarkdown(openFile.name)) {
|
|
1394
|
-
setFileLoading(true);
|
|
1395
|
-
try {
|
|
1396
|
-
await useEditorStore.getState().loadPage(openFile.path);
|
|
1397
|
-
} finally {
|
|
1398
|
-
setFileLoading(false);
|
|
1399
|
-
}
|
|
1400
|
-
return;
|
|
1401
|
-
}
|
|
1402
|
-
const kind = viewerKindFor(openFile.name, openFile.nodeType);
|
|
1403
|
-
if (!["editor", "text"].includes(kind) && !isText(openFile.name)) return;
|
|
1404
|
-
setFileLoading(true);
|
|
1405
|
-
try {
|
|
1406
|
-
const res = await wsFetch(
|
|
1407
|
-
`/api/wiki/content?path=${encodeURIComponent(openFile.path)}`,
|
|
1408
|
-
);
|
|
1409
|
-
if (res.ok) {
|
|
1410
|
-
const d: { content: string } = await res.json();
|
|
1411
|
-
setFileContent(d.content);
|
|
1412
|
-
setFileRevision(Number(res.headers.get("X-Wiki-Revision") ?? 0));
|
|
1413
|
-
}
|
|
1414
|
-
} catch {
|
|
1415
|
-
/* ignore */
|
|
1416
|
-
}
|
|
1417
|
-
setFileLoading(false);
|
|
1418
|
-
}, [openFile]);
|
|
1419
|
-
|
|
1420
|
-
useEffect(() => {
|
|
1421
|
-
if (!openFile || !isMarkdown(openFile.name)) return;
|
|
1422
|
-
if (useEditorStore.getState().currentPath === openFile.path) return;
|
|
1423
|
-
void useEditorStore.getState().loadPage(openFile.path);
|
|
1424
|
-
}, [openFile]);
|
|
1425
|
-
|
|
1426
|
-
// Keep watcher refs in sync
|
|
1427
|
-
useEffect(() => {
|
|
1428
|
-
openFileRef.current = openFile;
|
|
1429
|
-
}, [openFile]);
|
|
1430
|
-
useEffect(() => {
|
|
1431
|
-
editingRef.current = editing;
|
|
1432
|
-
}, [editing]);
|
|
1433
|
-
useEffect(() => {
|
|
1434
|
-
refreshViewerRef.current = refreshViewer;
|
|
1435
|
-
}, [refreshViewer]);
|
|
1436
|
-
|
|
1437
|
-
// File watcher: auto-update tree + open file via SSE
|
|
1438
|
-
useEffect(() => {
|
|
1439
|
-
if (!rootConfigured) return;
|
|
1440
|
-
|
|
1441
|
-
const pendingReloads = new Map<string, ReturnType<typeof setTimeout>>();
|
|
1442
|
-
|
|
1443
|
-
function scheduleReload(dir: string) {
|
|
1444
|
-
if (pendingReloads.has(dir)) clearTimeout(pendingReloads.get(dir)!);
|
|
1445
|
-
pendingReloads.set(
|
|
1446
|
-
dir,
|
|
1447
|
-
setTimeout(() => {
|
|
1448
|
-
pendingReloads.delete(dir);
|
|
1449
|
-
void reloadDir(dir);
|
|
1450
|
-
}, 300),
|
|
1451
|
-
);
|
|
1452
|
-
}
|
|
1453
|
-
|
|
1454
|
-
const es = new EventSource(withWs("/api/wiki/watch"));
|
|
1455
|
-
|
|
1456
|
-
es.onmessage = (event) => {
|
|
1457
|
-
let data: { type: string; path: string };
|
|
1458
|
-
try {
|
|
1459
|
-
data = JSON.parse(event.data as string) as {
|
|
1460
|
-
type: string;
|
|
1461
|
-
path: string;
|
|
1462
|
-
};
|
|
1463
|
-
} catch {
|
|
1464
|
-
return;
|
|
1465
|
-
}
|
|
1466
|
-
const { type, path: relPath } = data;
|
|
1467
|
-
|
|
1468
|
-
// Reload the affected parent dir in the tree
|
|
1469
|
-
const parts = relPath.split("/");
|
|
1470
|
-
const parentDir =
|
|
1471
|
-
parts.length > 1 ? parts.slice(0, -1).join("/") : "";
|
|
1472
|
-
scheduleReload(parentDir);
|
|
1473
|
-
|
|
1474
|
-
// If the open file changed externally and we're not editing, refresh it
|
|
1475
|
-
if (
|
|
1476
|
-
type === "change" &&
|
|
1477
|
-
openFileRef.current?.path === relPath &&
|
|
1478
|
-
!editingRef.current
|
|
1479
|
-
) {
|
|
1480
|
-
const key = `__file__${relPath}`;
|
|
1481
|
-
if (pendingReloads.has(key)) clearTimeout(pendingReloads.get(key)!);
|
|
1482
|
-
pendingReloads.set(
|
|
1483
|
-
key,
|
|
1484
|
-
setTimeout(() => {
|
|
1485
|
-
pendingReloads.delete(key);
|
|
1486
|
-
void refreshViewerRef.current();
|
|
1487
|
-
}, 400),
|
|
1488
|
-
);
|
|
1489
|
-
}
|
|
1490
|
-
};
|
|
1491
|
-
|
|
1492
|
-
return () => {
|
|
1493
|
-
es.close();
|
|
1494
|
-
for (const t of pendingReloads.values()) clearTimeout(t);
|
|
1495
|
-
};
|
|
1496
|
-
}, [rootConfigured, reloadDir]);
|
|
1497
|
-
|
|
1498
|
-
async function toggleFolder(node: TreeNode) {
|
|
1499
|
-
if (node.type !== "dir" && node.type !== "app" && node.type !== "node-app") return;
|
|
1500
|
-
if (!node.expanded) {
|
|
1501
|
-
if (node.children === undefined) {
|
|
1502
|
-
const prefetched = await takePrefetchedDir(node.path);
|
|
1503
|
-
if (prefetched) {
|
|
1504
|
-
setRoots((prev) =>
|
|
1505
|
-
updateNodes(prev, node.path, (n) => ({
|
|
1506
|
-
...n,
|
|
1507
|
-
loading: false,
|
|
1508
|
-
children: prefetched,
|
|
1509
|
-
expanded: true,
|
|
1510
|
-
})),
|
|
1511
|
-
);
|
|
1512
|
-
return;
|
|
1513
|
-
}
|
|
1514
|
-
setRoots((prev) =>
|
|
1515
|
-
updateNodes(prev, node.path, (n) => ({ ...n, loading: true })),
|
|
1516
|
-
);
|
|
1517
|
-
const children = await fetchDir(node.path);
|
|
1518
|
-
setRoots((prev) =>
|
|
1519
|
-
updateNodes(prev, node.path, (n) => ({
|
|
1520
|
-
...n,
|
|
1521
|
-
loading: false,
|
|
1522
|
-
children,
|
|
1523
|
-
expanded: true,
|
|
1524
|
-
})),
|
|
1525
|
-
);
|
|
1526
|
-
} else {
|
|
1527
|
-
setRoots((prev) =>
|
|
1528
|
-
updateNodes(prev, node.path, (n) => ({ ...n, expanded: true })),
|
|
1529
|
-
);
|
|
1530
|
-
}
|
|
1531
|
-
} else {
|
|
1532
|
-
setRoots((prev) =>
|
|
1533
|
-
updateNodes(prev, node.path, (n) => ({ ...n, expanded: false })),
|
|
1534
|
-
);
|
|
1535
|
-
}
|
|
1536
|
-
}
|
|
1537
|
-
|
|
1538
|
-
useEffect(() => {
|
|
1539
|
-
setAppFullscreen(false);
|
|
1540
|
-
}, []);
|
|
1541
|
-
|
|
1542
|
-
async function openViewer(node: TreeNode) {
|
|
1543
|
-
// Push to recent files (only real files, not dirs/apps)
|
|
1544
|
-
if (node.type === "file") {
|
|
1545
|
-
useRecentStore.getState().push(
|
|
1546
|
-
{ path: node.path, name: node.name },
|
|
1547
|
-
activeWorkspaceId,
|
|
1548
|
-
);
|
|
1549
|
-
}
|
|
1550
|
-
setOpenFile({
|
|
1551
|
-
path: node.path,
|
|
1552
|
-
name: node.name,
|
|
1553
|
-
size: node.size,
|
|
1554
|
-
nodeType:
|
|
1555
|
-
node.type === "app"
|
|
1556
|
-
? "app"
|
|
1557
|
-
: node.type === "node-app"
|
|
1558
|
-
? "node-app"
|
|
1559
|
-
: "file",
|
|
1560
|
-
});
|
|
1561
|
-
setEditing(false);
|
|
1562
|
-
setSaveError(null);
|
|
1563
|
-
setFileContent(null);
|
|
1564
|
-
setFileRevision(0);
|
|
1565
|
-
if (node.type === "file" && isMarkdown(node.name)) {
|
|
1566
|
-
setFileLoading(true);
|
|
1567
|
-
try {
|
|
1568
|
-
await useEditorStore.getState().loadPage(node.path);
|
|
1569
|
-
} finally {
|
|
1570
|
-
setFileLoading(false);
|
|
1571
|
-
}
|
|
1572
|
-
return;
|
|
1573
|
-
}
|
|
1574
|
-
const kind = viewerKindFor(node.name, node.type);
|
|
1575
|
-
if (!["editor", "text"].includes(kind) && !isText(node.name)) return;
|
|
1576
|
-
setFileLoading(true);
|
|
1577
|
-
try {
|
|
1578
|
-
const res = await wsFetch(
|
|
1579
|
-
`/api/wiki/content?path=${encodeURIComponent(node.path)}`,
|
|
1580
|
-
);
|
|
1581
|
-
if (res.ok) {
|
|
1582
|
-
const d: { content: string } = await res.json();
|
|
1583
|
-
setFileContent(d.content);
|
|
1584
|
-
setFileRevision(Number(res.headers.get("X-Wiki-Revision") ?? 0));
|
|
1585
|
-
}
|
|
1586
|
-
} catch {
|
|
1587
|
-
/* ignore */
|
|
1588
|
-
}
|
|
1589
|
-
setFileLoading(false);
|
|
1590
|
-
}
|
|
1591
|
-
|
|
1592
|
-
// Open a file from a search result: reveal it in the tree, then open it.
|
|
1593
|
-
const openFromSearch = useCallback(
|
|
1594
|
-
(relPath: string) => {
|
|
1595
|
-
const name = relPath.split("/").pop() ?? relPath;
|
|
1596
|
-
void revealPath(relPath);
|
|
1597
|
-
void openViewer({ path: relPath, name, type: "file" } as TreeNode);
|
|
1598
|
-
},
|
|
1599
|
-
[revealPath],
|
|
1600
|
-
);
|
|
1601
|
-
|
|
1602
|
-
// biome-ignore lint/correctness/useExhaustiveDependencies: only react to editor path changes
|
|
1603
|
-
useEffect(() => {
|
|
1604
|
-
if (!editorCurrentPath) return;
|
|
1605
|
-
if (openFile && openFile.path === editorCurrentPath) return;
|
|
1606
|
-
const name = editorCurrentPath.split("/").pop() ?? editorCurrentPath;
|
|
1607
|
-
void openViewer({
|
|
1608
|
-
path: editorCurrentPath,
|
|
1609
|
-
name,
|
|
1610
|
-
type: "file",
|
|
1611
|
-
} as TreeNode);
|
|
1612
|
-
}, [editorCurrentPath]);
|
|
1613
|
-
|
|
1614
|
-
// Persist the open file to the URL (?path=) so reloads restore it.
|
|
1615
|
-
// replaceState avoids polluting history on every file switch.
|
|
1616
|
-
useEffect(() => {
|
|
1617
|
-
if (typeof window === "undefined") return;
|
|
1618
|
-
const url = new URL(window.location.href);
|
|
1619
|
-
if (openFile) url.searchParams.set("path", openFile.path);
|
|
1620
|
-
else url.searchParams.delete("path");
|
|
1621
|
-
window.history.replaceState(null, "", url.toString());
|
|
1622
|
-
}, [openFile]);
|
|
1623
|
-
|
|
1624
|
-
// Restore the open file from the URL once the root tree is loaded.
|
|
1625
|
-
// Resolves node type from the parent dir listing (file vs app vs node-app).
|
|
1626
|
-
// biome-ignore lint/correctness/useExhaustiveDependencies: openViewer is a hoisted stable fn; restore runs once
|
|
1627
|
-
useEffect(() => {
|
|
1628
|
-
if (didRestoreRef.current) return;
|
|
1629
|
-
if (!rootLoaded) return;
|
|
1630
|
-
const target = initialUrlPathRef.current;
|
|
1631
|
-
if (!target) {
|
|
1632
|
-
didRestoreRef.current = true;
|
|
1633
|
-
return;
|
|
1634
|
-
}
|
|
1635
|
-
didRestoreRef.current = true;
|
|
1636
|
-
|
|
1637
|
-
void (async () => {
|
|
1638
|
-
const parts = target.split("/");
|
|
1639
|
-
const name = parts[parts.length - 1];
|
|
1640
|
-
const parentDir = parts.slice(0, -1).join("/");
|
|
1641
|
-
const siblings = await fetchDir(parentDir);
|
|
1642
|
-
const match = siblings.find((s) => s.path === target);
|
|
1643
|
-
if (!match) return; // file no longer exists; leave home view
|
|
1644
|
-
await revealPath(target);
|
|
1645
|
-
void openViewer({
|
|
1646
|
-
path: target,
|
|
1647
|
-
name,
|
|
1648
|
-
type: match.type,
|
|
1649
|
-
modifiedAt: match.modifiedAt,
|
|
1650
|
-
} as TreeNode);
|
|
1651
|
-
})();
|
|
1652
|
-
}, [rootLoaded, revealPath]);
|
|
1653
|
-
|
|
1654
|
-
const switchWorkspace = useCallback(async (id: string) => {
|
|
1655
|
-
if (id === activeWorkspaceId) return;
|
|
1656
|
-
try {
|
|
1657
|
-
await fetch(`/api/system/workspaces/${id}/open`, { method: "POST" });
|
|
1658
|
-
} catch {
|
|
1659
|
-
/* best-effort lastOpened bump */
|
|
1660
|
-
}
|
|
1661
|
-
const u = new URL(location.href);
|
|
1662
|
-
u.searchParams.set("ws", id);
|
|
1663
|
-
u.searchParams.delete("path");
|
|
1664
|
-
history.pushState(null, "", u.toString());
|
|
1665
|
-
// Reset open file + caches; the key={activeWorkspaceId} remount clears the rest.
|
|
1666
|
-
setOpenFile(null);
|
|
1667
|
-
setFileContent(null);
|
|
1668
|
-
setEditing(false);
|
|
1669
|
-
setRoots([]);
|
|
1670
|
-
setRootLoaded(false);
|
|
1671
|
-
rootLoadingRef.current = false;
|
|
1672
|
-
useWikiSlugsStore.getState().invalidate();
|
|
1673
|
-
const ws = workspaces.find((w) => w.id === id);
|
|
1674
|
-
if (ws) setRootPath(ws.rootDir);
|
|
1675
|
-
setActiveWorkspaceId(id);
|
|
1676
|
-
}, [activeWorkspaceId, workspaces]);
|
|
1677
|
-
|
|
1678
|
-
const handleDeleteWorkspace = useCallback(async () => {
|
|
1679
|
-
if (!deletingWorkspaceId) return;
|
|
1680
|
-
try {
|
|
1681
|
-
const res = await fetch(`/api/system/workspaces/${deletingWorkspaceId}`, {
|
|
1682
|
-
method: "DELETE",
|
|
1683
|
-
});
|
|
1684
|
-
if (!res.ok) throw new Error("Failed");
|
|
1685
|
-
// If deleting the active workspace, switch to first available or picker
|
|
1686
|
-
if (deletingWorkspaceId === activeWorkspaceId) {
|
|
1687
|
-
const remaining = workspaces.filter((w) => w.id !== deletingWorkspaceId);
|
|
1688
|
-
if (remaining.length > 0) {
|
|
1689
|
-
const next = [...remaining].sort(
|
|
1690
|
-
(a, b) => (b.lastOpenedAt ?? b.createdAt).localeCompare(a.lastOpenedAt ?? a.createdAt)
|
|
1691
|
-
)[0];
|
|
1692
|
-
await switchWorkspace(next.id);
|
|
1693
|
-
} else {
|
|
1694
|
-
setRootConfigured(false);
|
|
1695
|
-
}
|
|
1696
|
-
}
|
|
1697
|
-
await loadWorkspaces();
|
|
1698
|
-
} catch {
|
|
1699
|
-
/* ignore */
|
|
1700
|
-
} finally {
|
|
1701
|
-
setDeletingWorkspaceId(null);
|
|
1702
|
-
}
|
|
1703
|
-
}, [deletingWorkspaceId, activeWorkspaceId, workspaces, switchWorkspace, loadWorkspaces]);
|
|
1704
|
-
|
|
1705
|
-
const handleRefreshWorkspace = useCallback(async (id: string) => {
|
|
1706
|
-
if (refreshingWsId) return;
|
|
1707
|
-
setRefreshingWsId(id);
|
|
1708
|
-
try {
|
|
1709
|
-
const res = await fetch(`/api/system/workspaces/${id}/refresh`, { method: "POST" });
|
|
1710
|
-
if (!res.ok) {
|
|
1711
|
-
const e: { error?: string } = await res.json();
|
|
1712
|
-
showError(e.error ?? "Refresh failed");
|
|
1713
|
-
}
|
|
1714
|
-
await loadWorkspaces();
|
|
1715
|
-
} catch {
|
|
1716
|
-
showError("Refresh failed");
|
|
1717
|
-
} finally {
|
|
1718
|
-
setRefreshingWsId(null);
|
|
1719
|
-
}
|
|
1720
|
-
}, [refreshingWsId, loadWorkspaces]);
|
|
1721
|
-
|
|
1722
|
-
async function handleSave() {
|
|
1723
|
-
if (!openFile) return;
|
|
1724
|
-
setSaving(true);
|
|
1725
|
-
setSaveError(null);
|
|
1726
|
-
const res = await wsFetch("/api/wiki/content", {
|
|
1727
|
-
method: "PUT",
|
|
1728
|
-
headers: { "Content-Type": "application/json" },
|
|
1729
|
-
body: JSON.stringify({
|
|
1730
|
-
path: openFile.path,
|
|
1731
|
-
content: editContent,
|
|
1732
|
-
baseRevision: fileRevision,
|
|
1733
|
-
}),
|
|
1734
|
-
});
|
|
1735
|
-
if (res.ok) {
|
|
1736
|
-
const d: { revision?: number } = await res.json();
|
|
1737
|
-
if (typeof d.revision === "number") setFileRevision(d.revision);
|
|
1738
|
-
setFileContent(editContent);
|
|
1739
|
-
setEditing(false);
|
|
1740
|
-
} else {
|
|
1741
|
-
const e: { error?: string } = await res.json();
|
|
1742
|
-
setSaveError(e.error ?? "Save failed");
|
|
1743
|
-
}
|
|
1744
|
-
setSaving(false);
|
|
1745
|
-
}
|
|
1746
|
-
|
|
1747
|
-
const doUpload = useCallback(
|
|
1748
|
-
async (files: FileList | File[], dir: string) => {
|
|
1749
|
-
const list = Array.from(files);
|
|
1750
|
-
if (!list.length) return;
|
|
1751
|
-
setUploading(true);
|
|
1752
|
-
setUploadError(null);
|
|
1753
|
-
try {
|
|
1754
|
-
for (const file of list) {
|
|
1755
|
-
const fd = new FormData();
|
|
1756
|
-
fd.append("file", file);
|
|
1757
|
-
fd.append("dir", dir);
|
|
1758
|
-
const res = await wsFetch("/api/wiki/upload", {
|
|
1759
|
-
method: "POST",
|
|
1760
|
-
body: fd,
|
|
1761
|
-
});
|
|
1762
|
-
if (!res.ok) {
|
|
1763
|
-
const e: { error?: string } = await res.json();
|
|
1764
|
-
setUploadError(e.error ?? "Upload failed");
|
|
1765
|
-
showError(e.error ?? "Upload failed");
|
|
1766
|
-
break;
|
|
1767
|
-
}
|
|
1768
|
-
}
|
|
1769
|
-
await reloadDir(dir);
|
|
1770
|
-
} catch {
|
|
1771
|
-
setUploadError("Upload failed.");
|
|
1772
|
-
} finally {
|
|
1773
|
-
setUploading(false);
|
|
1774
|
-
if (fileInputRef.current) fileInputRef.current.value = "";
|
|
1775
|
-
}
|
|
1776
|
-
},
|
|
1777
|
-
[reloadDir],
|
|
1778
|
-
);
|
|
1779
|
-
|
|
1780
|
-
function triggerUpload(dir: string) {
|
|
1781
|
-
uploadDirRef.current = dir;
|
|
1782
|
-
fileInputRef.current?.click();
|
|
1783
|
-
}
|
|
1784
|
-
|
|
1785
|
-
async function handleCreateFolder() {
|
|
1786
|
-
const name = newFolderName.trim();
|
|
1787
|
-
if (!name || newFolderParent === null) return;
|
|
1788
|
-
setFolderError(null);
|
|
1789
|
-
const rel = newFolderParent ? `${newFolderParent}/${name}` : name;
|
|
1790
|
-
const res = await wsFetch("/api/wiki/folder", {
|
|
1791
|
-
method: "POST",
|
|
1792
|
-
headers: { "Content-Type": "application/json" },
|
|
1793
|
-
body: JSON.stringify({ path: rel }),
|
|
1794
|
-
});
|
|
1795
|
-
if (res.ok) {
|
|
1796
|
-
setNewFolderParent(null);
|
|
1797
|
-
setNewFolderName("");
|
|
1798
|
-
await reloadDir(newFolderParent);
|
|
1799
|
-
if (newFolderParent !== "") {
|
|
1800
|
-
setRoots((prev) =>
|
|
1801
|
-
updateNodes(prev, newFolderParent, (n) => ({ ...n, expanded: true })),
|
|
1802
|
-
);
|
|
1803
|
-
}
|
|
1804
|
-
} else {
|
|
1805
|
-
const e: { error?: string } = await res.json();
|
|
1806
|
-
setFolderError(e.error ?? "Failed");
|
|
1807
|
-
}
|
|
1808
|
-
}
|
|
1809
|
-
|
|
1810
|
-
async function handleCreateFile() {
|
|
1811
|
-
const raw = newFileName.trim();
|
|
1812
|
-
if (!raw || newFileParent === null) return;
|
|
1813
|
-
setFileCreateError(null);
|
|
1814
|
-
const name = raw.includes(".") ? raw : `${raw}.md`;
|
|
1815
|
-
const rel = newFileParent ? `${newFileParent}/${name}` : name;
|
|
1816
|
-
const res = await wsFetch("/api/wiki/new-file", {
|
|
1817
|
-
method: "POST",
|
|
1818
|
-
headers: { "Content-Type": "application/json" },
|
|
1819
|
-
body: JSON.stringify({ path: rel }),
|
|
1820
|
-
});
|
|
1821
|
-
if (res.ok) {
|
|
1822
|
-
const parent = newFileParent;
|
|
1823
|
-
setNewFileParent(null);
|
|
1824
|
-
setNewFileName("");
|
|
1825
|
-
await reloadDir(parent);
|
|
1826
|
-
if (parent !== "") {
|
|
1827
|
-
setRoots((prev) =>
|
|
1828
|
-
updateNodes(prev, parent, (n) => ({ ...n, expanded: true })),
|
|
1829
|
-
);
|
|
1830
|
-
}
|
|
1831
|
-
void openViewer({
|
|
1832
|
-
path: rel,
|
|
1833
|
-
name,
|
|
1834
|
-
type: "file",
|
|
1835
|
-
modifiedAt: new Date().toISOString(),
|
|
1836
|
-
} as TreeNode);
|
|
1837
|
-
} else {
|
|
1838
|
-
const e: { error?: string } = await res.json();
|
|
1839
|
-
setFileCreateError(e.error ?? "Failed");
|
|
1840
|
-
}
|
|
1841
|
-
}
|
|
1842
|
-
|
|
1843
|
-
function handleDownload(node: TreeNode) {
|
|
1844
|
-
const url = withWs(`/api/wiki/download?path=${encodeURIComponent(node.path)}`);
|
|
1845
|
-
const a = document.createElement("a");
|
|
1846
|
-
a.href = url;
|
|
1847
|
-
a.download = node.type === "file" ? node.name : `${node.name}.zip`;
|
|
1848
|
-
document.body.appendChild(a);
|
|
1849
|
-
a.click();
|
|
1850
|
-
a.remove();
|
|
1851
|
-
}
|
|
1852
|
-
|
|
1853
|
-
function copyPath(path: string) {
|
|
1854
|
-
void navigator.clipboard.writeText(path);
|
|
1855
|
-
showSuccess("Path copied");
|
|
1856
|
-
}
|
|
1857
|
-
|
|
1858
|
-
function copyWikiLink(name: string) {
|
|
1859
|
-
const slug = name.replace(/\.(md|markdown)$/i, "");
|
|
1860
|
-
void navigator.clipboard.writeText(`[[${slug}]]`);
|
|
1861
|
-
showSuccess("Wiki link copied");
|
|
1862
|
-
}
|
|
1863
|
-
|
|
1864
|
-
function copyUrl(path: string) {
|
|
1865
|
-
const url = new URL(location.href);
|
|
1866
|
-
url.searchParams.set("path", path);
|
|
1867
|
-
if (activeWorkspaceId) url.searchParams.set("ws", activeWorkspaceId);
|
|
1868
|
-
void navigator.clipboard.writeText(url.toString());
|
|
1869
|
-
showSuccess("URL copied");
|
|
1870
|
-
}
|
|
1871
|
-
|
|
1872
|
-
async function getTextContent(path: string) {
|
|
1873
|
-
if (openFile?.path === path && fileContent !== null) return fileContent;
|
|
1874
|
-
if (useEditorStore.getState().currentPath === path) {
|
|
1875
|
-
return useEditorStore.getState().content;
|
|
1876
|
-
}
|
|
1877
|
-
const res = await wsFetch(`/api/wiki/content?path=${encodeURIComponent(path)}`);
|
|
1878
|
-
if (!res.ok) throw new Error("Cannot copy content");
|
|
1879
|
-
const data: { content: string } = await res.json();
|
|
1880
|
-
return data.content;
|
|
1881
|
-
}
|
|
1882
|
-
|
|
1883
|
-
async function copyRawContent(path: string) {
|
|
1884
|
-
try {
|
|
1885
|
-
const content = await getTextContent(path);
|
|
1886
|
-
await navigator.clipboard.writeText(content);
|
|
1887
|
-
showSuccess("Raw content copied");
|
|
1888
|
-
} catch {
|
|
1889
|
-
showError("Could not copy file content");
|
|
1890
|
-
}
|
|
1891
|
-
}
|
|
1892
|
-
|
|
1893
|
-
async function copyFormattedContent(path: string, name: string) {
|
|
1894
|
-
try {
|
|
1895
|
-
const content = await getTextContent(path);
|
|
1896
|
-
if (!isMarkdown(name)) {
|
|
1897
|
-
await navigator.clipboard.writeText(content);
|
|
1898
|
-
showSuccess("Content copied");
|
|
1899
|
-
return;
|
|
1900
|
-
}
|
|
1901
|
-
const html = await markdownToHtml(content, { pagePath: path, sanitize: true });
|
|
1902
|
-
if ("ClipboardItem" in window && navigator.clipboard.write) {
|
|
1903
|
-
await navigator.clipboard.write([
|
|
1904
|
-
new ClipboardItem({
|
|
1905
|
-
"text/html": new Blob([html], { type: "text/html" }),
|
|
1906
|
-
"text/plain": new Blob([content], { type: "text/plain" }),
|
|
1907
|
-
}),
|
|
1908
|
-
]);
|
|
1909
|
-
} else {
|
|
1910
|
-
await navigator.clipboard.writeText(content);
|
|
1911
|
-
}
|
|
1912
|
-
showSuccess("Formatted content copied");
|
|
1913
|
-
} catch {
|
|
1914
|
-
showError("Could not copy formatted content");
|
|
1915
|
-
}
|
|
1916
|
-
}
|
|
1917
|
-
|
|
1918
|
-
function renderCopyMenu(node: { path: string; name: string }) {
|
|
1919
|
-
return (
|
|
1920
|
-
<DropdownMenu>
|
|
1921
|
-
<DropdownMenuTrigger asChild>
|
|
1922
|
-
<Button
|
|
1923
|
-
size="sm"
|
|
1924
|
-
variant="ghost"
|
|
1925
|
-
className="h-7 gap-1.5 px-2 text-xs"
|
|
1926
|
-
title="Copy path, wiki link, or URL"
|
|
1927
|
-
>
|
|
1928
|
-
<Copy className="h-3.5 w-3.5" />
|
|
1929
|
-
Copy
|
|
1930
|
-
<ChevronDown className="h-3 w-3 opacity-60" />
|
|
1931
|
-
</Button>
|
|
1932
|
-
</DropdownMenuTrigger>
|
|
1933
|
-
<DropdownMenuContent align="end" className="w-44">
|
|
1934
|
-
<DropdownMenuItem onClick={() => copyPath(node.path)}>
|
|
1935
|
-
<Copy className="mr-2 h-3.5 w-3.5" />
|
|
1936
|
-
Copy path
|
|
1937
|
-
</DropdownMenuItem>
|
|
1938
|
-
{isMarkdown(node.name) && (
|
|
1939
|
-
<DropdownMenuItem onClick={() => copyWikiLink(node.name)}>
|
|
1940
|
-
<FileText className="mr-2 h-3.5 w-3.5" />
|
|
1941
|
-
Copy wiki link
|
|
1942
|
-
</DropdownMenuItem>
|
|
1943
|
-
)}
|
|
1944
|
-
<DropdownMenuSeparator />
|
|
1945
|
-
<DropdownMenuItem onClick={() => copyUrl(node.path)}>
|
|
1946
|
-
<Link className="mr-2 h-3.5 w-3.5" />
|
|
1947
|
-
Copy URL
|
|
1948
|
-
</DropdownMenuItem>
|
|
1949
|
-
{isText(node.name) && (
|
|
1950
|
-
<>
|
|
1951
|
-
<DropdownMenuSeparator />
|
|
1952
|
-
<DropdownMenuItem onClick={() => void copyRawContent(node.path)}>
|
|
1953
|
-
<FileText className="mr-2 h-3.5 w-3.5" />
|
|
1954
|
-
Copy raw content
|
|
1955
|
-
</DropdownMenuItem>
|
|
1956
|
-
<DropdownMenuItem onClick={() => void copyFormattedContent(node.path, node.name)}>
|
|
1957
|
-
<FileText className="mr-2 h-3.5 w-3.5" />
|
|
1958
|
-
Copy formatted content
|
|
1959
|
-
</DropdownMenuItem>
|
|
1960
|
-
</>
|
|
1961
|
-
)}
|
|
1962
|
-
</DropdownMenuContent>
|
|
1963
|
-
</DropdownMenu>
|
|
1964
|
-
);
|
|
1965
|
-
}
|
|
1966
|
-
|
|
1967
|
-
async function handleDelete() {
|
|
1968
|
-
if (!deletingPath) return;
|
|
1969
|
-
await wsFetch("/api/wiki", {
|
|
1970
|
-
method: "DELETE",
|
|
1971
|
-
headers: { "Content-Type": "application/json" },
|
|
1972
|
-
body: JSON.stringify({ path: deletingPath }),
|
|
1973
|
-
});
|
|
1974
|
-
if (
|
|
1975
|
-
openFile?.path === deletingPath ||
|
|
1976
|
-
openFile?.path.startsWith(`${deletingPath}/`)
|
|
1977
|
-
) {
|
|
1978
|
-
setOpenFile(null);
|
|
1979
|
-
setFileContent(null);
|
|
1980
|
-
}
|
|
1981
|
-
setRoots((prev) => removeNode(prev, deletingPath));
|
|
1982
|
-
setDeletingPath(null);
|
|
1983
|
-
}
|
|
1984
|
-
|
|
1985
|
-
function handleDragStart(e: React.DragEvent, node: TreeNode) {
|
|
1986
|
-
dragNodeRef.current = node;
|
|
1987
|
-
e.dataTransfer.effectAllowed = "move";
|
|
1988
|
-
e.dataTransfer.setData("text/plain", node.path);
|
|
1989
|
-
}
|
|
1990
|
-
|
|
1991
|
-
function handleDragOver(
|
|
1992
|
-
e: React.DragEvent,
|
|
1993
|
-
targetPath: string,
|
|
1994
|
-
targetType: "dir" | "root",
|
|
1995
|
-
) {
|
|
1996
|
-
e.preventDefault();
|
|
1997
|
-
e.stopPropagation();
|
|
1998
|
-
const dragging = dragNodeRef.current;
|
|
1999
|
-
if (!dragging) return;
|
|
2000
|
-
if (
|
|
2001
|
-
dragging.path === targetPath ||
|
|
2002
|
-
targetPath.startsWith(`${dragging.path}/`)
|
|
2003
|
-
)
|
|
2004
|
-
return;
|
|
2005
|
-
e.dataTransfer.dropEffect = "move";
|
|
2006
|
-
setDragOverPath(targetType === "root" ? "" : targetPath);
|
|
2007
|
-
}
|
|
2008
|
-
|
|
2009
|
-
async function handleDropOnFolder(e: React.DragEvent, targetDirPath: string) {
|
|
2010
|
-
e.preventDefault();
|
|
2011
|
-
e.stopPropagation();
|
|
2012
|
-
setDragOverPath(null);
|
|
2013
|
-
const node = dragNodeRef.current;
|
|
2014
|
-
dragNodeRef.current = null;
|
|
2015
|
-
if (!node) return;
|
|
2016
|
-
if (
|
|
2017
|
-
node.path === targetDirPath ||
|
|
2018
|
-
targetDirPath.startsWith(`${node.path}/`)
|
|
2019
|
-
)
|
|
2020
|
-
return;
|
|
2021
|
-
|
|
2022
|
-
const newPath = targetDirPath ? `${targetDirPath}/${node.name}` : node.name;
|
|
2023
|
-
if (newPath === node.path) return;
|
|
2024
|
-
|
|
2025
|
-
const res = await wsFetch("/api/wiki/move", {
|
|
2026
|
-
method: "POST",
|
|
2027
|
-
headers: { "Content-Type": "application/json" },
|
|
2028
|
-
body: JSON.stringify({ from: node.path, to: newPath }),
|
|
2029
|
-
});
|
|
2030
|
-
if (res.ok) {
|
|
2031
|
-
const sourceParent = node.path.includes("/")
|
|
2032
|
-
? node.path.split("/").slice(0, -1).join("/")
|
|
2033
|
-
: "";
|
|
2034
|
-
await reloadDir(sourceParent);
|
|
2035
|
-
if (targetDirPath !== sourceParent) await reloadDir(targetDirPath);
|
|
2036
|
-
if (openFile?.path === node.path)
|
|
2037
|
-
setOpenFile({
|
|
2038
|
-
path: newPath,
|
|
2039
|
-
name: node.name,
|
|
2040
|
-
nodeType: openFile.nodeType,
|
|
2041
|
-
size: openFile.size,
|
|
2042
|
-
});
|
|
2043
|
-
}
|
|
2044
|
-
}
|
|
2045
|
-
|
|
2046
|
-
const openFileViewerKind = openFile
|
|
2047
|
-
? viewerKindFor(openFile.name, openFile.nodeType)
|
|
2048
|
-
: null;
|
|
2049
|
-
|
|
2050
|
-
// Gate everything not explicitly safe. Markdown ("editor") is gated too:
|
|
2051
|
-
// TipTap builds a node per block and freezes on big docs.
|
|
2052
|
-
const showLargeFileGate =
|
|
2053
|
-
!!openFile &&
|
|
2054
|
-
openFileViewerKind !== null &&
|
|
2055
|
-
!SAFE_VIEWER_KINDS.has(openFileViewerKind) &&
|
|
2056
|
-
(openFile.size ?? 0) > LARGE_FILE_GATE_BYTES &&
|
|
2057
|
-
gateBypassPath !== openFile.path;
|
|
2058
|
-
|
|
2059
|
-
const viewWidth = useViewWidthStore((s) => s.width);
|
|
2060
|
-
const viewAlign = useViewWidthStore((s) => s.align);
|
|
2061
|
-
// Width toggle only meaningful for text-flow viewers (long prose lines).
|
|
2062
|
-
const widthAwareViewer =
|
|
2063
|
-
openFileViewerKind === null ||
|
|
2064
|
-
openFileViewerKind === "editor" ||
|
|
2065
|
-
openFileViewerKind === "text" ||
|
|
2066
|
-
openFileViewerKind === "source" ||
|
|
2067
|
-
openFileViewerKind === "notebook" ||
|
|
2068
|
-
openFileViewerKind === "fallback";
|
|
2069
|
-
const contentWidthClass = widthAwareViewer ? VIEW_WIDTH_CLASS[viewWidth] : "";
|
|
2070
|
-
const contentAlignClass = widthAwareViewer ? VIEW_ALIGN_CLASS[viewAlign] : "";
|
|
2071
|
-
|
|
2072
|
-
// Ref-backed dispatcher: handlers below are recreated each render, but the
|
|
2073
|
-
// dispatcher methods are referentially stable, so FileTree's React.memo holds.
|
|
2074
|
-
const treeHandlersRef = useRef<TreeCtx | null>(null);
|
|
2075
|
-
treeHandlersRef.current = {
|
|
2076
|
-
toggleFolder: (n) => void toggleFolder(n),
|
|
2077
|
-
openViewer: (n) => void openViewer(n),
|
|
2078
|
-
copyPath,
|
|
2079
|
-
copyWikiLink,
|
|
2080
|
-
copyUrl,
|
|
2081
|
-
copyRawContent: (path) => void copyRawContent(path),
|
|
2082
|
-
copyFormattedContent: (path, name) => void copyFormattedContent(path, name),
|
|
2083
|
-
handleDownload,
|
|
2084
|
-
triggerUpload,
|
|
2085
|
-
handleCreateFile: () => void handleCreateFile(),
|
|
2086
|
-
handleCreateFolder: () => void handleCreateFolder(),
|
|
2087
|
-
handleDragStart,
|
|
2088
|
-
handleDragOver,
|
|
2089
|
-
handleDropOnFolder: (e, p) => void handleDropOnFolder(e, p),
|
|
2090
|
-
handleGitPull: (p, d) => void handleGitPull(p, d),
|
|
2091
|
-
handleCheckout: (p, b, d) => void handleCheckout(p, b, d),
|
|
2092
|
-
loadBranches: (p) => void loadBranches(p),
|
|
2093
|
-
prefetch: (node) => {
|
|
2094
|
-
if (node.type === "file") { if (isMarkdown(node.name)) prefetchPage(node.path); }
|
|
2095
|
-
else prefetchDir(node.path);
|
|
2096
|
-
},
|
|
2097
|
-
togglePin: (node, wsId) =>
|
|
2098
|
-
usePinStore.getState().toggle(
|
|
2099
|
-
{ path: node.path, name: node.name, type: (node.type === "dir" ? "file" : node.type) as "file" | "app" | "node-app" },
|
|
2100
|
-
wsId,
|
|
2101
|
-
),
|
|
2102
|
-
setDragOverPath,
|
|
2103
|
-
setSidebarCollapsed,
|
|
2104
|
-
setBranchDropdownNode,
|
|
2105
|
-
setBranchDropdownPos,
|
|
2106
|
-
setNewFileParent,
|
|
2107
|
-
setNewFileName,
|
|
2108
|
-
setFileCreateError,
|
|
2109
|
-
setNewFolderParent,
|
|
2110
|
-
setNewFolderName,
|
|
2111
|
-
setFolderError,
|
|
2112
|
-
setDeletingPath,
|
|
2113
|
-
setDeletingIsDir,
|
|
2114
|
-
};
|
|
2115
|
-
const treeCtx = useMemo<TreeCtx>(() => {
|
|
2116
|
-
const r = treeHandlersRef;
|
|
2117
|
-
return {
|
|
2118
|
-
toggleFolder: (n) => r.current!.toggleFolder(n),
|
|
2119
|
-
openViewer: (n) => r.current!.openViewer(n),
|
|
2120
|
-
copyPath: (p) => r.current!.copyPath(p),
|
|
2121
|
-
copyWikiLink: (n) => r.current!.copyWikiLink(n),
|
|
2122
|
-
copyUrl: (p) => r.current!.copyUrl(p),
|
|
2123
|
-
copyRawContent: (p) => r.current!.copyRawContent(p),
|
|
2124
|
-
copyFormattedContent: (p, n) => r.current!.copyFormattedContent(p, n),
|
|
2125
|
-
handleDownload: (n) => r.current!.handleDownload(n),
|
|
2126
|
-
triggerUpload: (d) => r.current!.triggerUpload(d),
|
|
2127
|
-
handleCreateFile: () => r.current!.handleCreateFile(),
|
|
2128
|
-
handleCreateFolder: () => r.current!.handleCreateFolder(),
|
|
2129
|
-
handleDragStart: (e, n) => r.current!.handleDragStart(e, n),
|
|
2130
|
-
handleDragOver: (e, p, t) => r.current!.handleDragOver(e, p, t),
|
|
2131
|
-
handleDropOnFolder: (e, p) => r.current!.handleDropOnFolder(e, p),
|
|
2132
|
-
handleGitPull: (p, d) => r.current!.handleGitPull(p, d),
|
|
2133
|
-
handleCheckout: (p, b, d) => r.current!.handleCheckout(p, b, d),
|
|
2134
|
-
loadBranches: (p) => r.current!.loadBranches(p),
|
|
2135
|
-
prefetch: (n) => r.current!.prefetch(n),
|
|
2136
|
-
togglePin: (n, w) => r.current!.togglePin(n, w),
|
|
2137
|
-
setDragOverPath: (p) => r.current!.setDragOverPath(p),
|
|
2138
|
-
setSidebarCollapsed: (b) => r.current!.setSidebarCollapsed(b),
|
|
2139
|
-
setBranchDropdownNode: (p) => r.current!.setBranchDropdownNode(p),
|
|
2140
|
-
setBranchDropdownPos: (p) => r.current!.setBranchDropdownPos(p),
|
|
2141
|
-
setNewFileParent: (p) => r.current!.setNewFileParent(p),
|
|
2142
|
-
setNewFileName: (s) => r.current!.setNewFileName(s),
|
|
2143
|
-
setFileCreateError: (s) => r.current!.setFileCreateError(s),
|
|
2144
|
-
setNewFolderParent: (p) => r.current!.setNewFolderParent(p),
|
|
2145
|
-
setNewFolderName: (s) => r.current!.setNewFolderName(s),
|
|
2146
|
-
setFolderError: (s) => r.current!.setFolderError(s),
|
|
2147
|
-
setDeletingPath: (p) => r.current!.setDeletingPath(p),
|
|
2148
|
-
setDeletingIsDir: (b) => r.current!.setDeletingIsDir(b),
|
|
2149
|
-
};
|
|
2150
|
-
}, []);
|
|
2151
|
-
|
|
2152
|
-
return (
|
|
2153
|
-
<div key={activeWorkspaceId ?? "none"} className="flex h-screen gap-0 overflow-hidden bg-background">
|
|
2154
|
-
{rootConfigured === null && (
|
|
2155
|
-
<div className="flex-1 flex items-center justify-center">
|
|
2156
|
-
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
|
|
2157
|
-
</div>
|
|
2158
|
-
)}
|
|
2159
|
-
{rootConfigured === false && (
|
|
2160
|
-
<DirPicker onSelect={(workspaceId) => {
|
|
2161
|
-
const u = new URL(location.href);
|
|
2162
|
-
u.searchParams.set("ws", workspaceId);
|
|
2163
|
-
u.searchParams.delete("path");
|
|
2164
|
-
history.replaceState(null, "", u.toString());
|
|
2165
|
-
setRootLoaded(false);
|
|
2166
|
-
rootLoadingRef.current = false;
|
|
2167
|
-
setActiveWorkspaceId(workspaceId);
|
|
2168
|
-
setRootConfigured(true);
|
|
2169
|
-
void loadWorkspaces();
|
|
2170
|
-
}} />
|
|
2171
|
-
)}
|
|
2172
|
-
{rootConfigured === true && addingWorkspace && (
|
|
2173
|
-
<div className="flex-1 flex flex-col">
|
|
2174
|
-
<div className="flex items-center justify-end border-b px-3 py-2 bg-muted shrink-0">
|
|
2175
|
-
<Button size="sm" variant="ghost" className="h-7 gap-1.5 text-xs" onClick={() => setAddingWorkspace(false)}>
|
|
2176
|
-
<X className="h-3.5 w-3.5" /> Cancel
|
|
2177
|
-
</Button>
|
|
2178
|
-
</div>
|
|
2179
|
-
<DirPicker onSelect={(workspaceId) => {
|
|
2180
|
-
setAddingWorkspace(false);
|
|
2181
|
-
void loadWorkspaces();
|
|
2182
|
-
void switchWorkspace(workspaceId);
|
|
2183
|
-
}} />
|
|
2184
|
-
</div>
|
|
2185
|
-
)}
|
|
2186
|
-
{rootConfigured === true && !addingWorkspace && <>
|
|
2187
|
-
<SearchCommandDialog
|
|
2188
|
-
onOpenFile={openFromSearch}
|
|
2189
|
-
onToggleSidebar={() =>
|
|
2190
|
-
setSidebarCollapsed((v) => {
|
|
2191
|
-
const next = !v;
|
|
2192
|
-
if (isMobile && next === false) useAIPanelStore.getState().close();
|
|
2193
|
-
return next;
|
|
2194
|
-
})
|
|
2195
|
-
}
|
|
2196
|
-
onNewFile={() => setNewFileParent("")}
|
|
2197
|
-
onCopyPath={() => {
|
|
2198
|
-
if (openFile) void navigator.clipboard.writeText(openFile.path);
|
|
2199
|
-
}}
|
|
2200
|
-
/>
|
|
2201
|
-
{/* Tree sidebar */}
|
|
2202
|
-
{!sidebarCollapsed && isMobile && (
|
|
2203
|
-
<div
|
|
2204
|
-
className="fixed inset-0 z-40 bg-overlay backdrop-blur-[1px] md:hidden"
|
|
2205
|
-
onClick={() => setSidebarCollapsed(true)}
|
|
2206
|
-
aria-hidden
|
|
2207
|
-
/>
|
|
2208
|
-
)}
|
|
2209
|
-
{!sidebarCollapsed && (
|
|
2210
|
-
<Card
|
|
2211
|
-
style={isMobile ? undefined : { width: sidebarWidth }}
|
|
2212
|
-
className="fixed inset-y-0 left-0 z-50 w-[85vw] max-w-[20rem] md:relative md:z-auto md:w-auto md:max-w-none flex flex-col shrink-0 overflow-hidden rounded-none border-r border-l-0 border-t-0 border-b-0">
|
|
2213
|
-
{/* Row 1: brand + collapse */}
|
|
2214
|
-
<div className="flex items-center justify-between gap-2 px-3 py-2 border-b bg-muted shrink-0">
|
|
2215
|
-
<div className="flex min-w-0 items-center gap-1.5">
|
|
2216
|
-
<img src="/logo.svg" alt="Wiki Viewer" className="h-5 w-5 shrink-0" />
|
|
2217
|
-
<span className="truncate text-xs font-semibold leading-5 tracking-tight translate-y-[0.5px]">
|
|
2218
|
-
Wiki Viewer
|
|
2219
|
-
</span>
|
|
2220
|
-
</div>
|
|
2221
|
-
<Button
|
|
2222
|
-
size="sm"
|
|
2223
|
-
variant="ghost"
|
|
2224
|
-
className="h-7 w-7 p-0 shrink-0"
|
|
2225
|
-
title="Collapse sidebar"
|
|
2226
|
-
onClick={() => setSidebarCollapsed(true)}
|
|
2227
|
-
>
|
|
2228
|
-
<PanelLeftClose className="h-3.5 w-3.5" />
|
|
2229
|
-
</Button>
|
|
2230
|
-
</div>
|
|
2231
|
-
|
|
2232
|
-
{/* Row 2: actions toolbar */}
|
|
2233
|
-
<div className="flex items-center justify-between gap-1 px-3 py-1.5 border-b bg-muted/50 shrink-0">
|
|
2234
|
-
<DropdownMenu>
|
|
2235
|
-
<DropdownMenuTrigger asChild>
|
|
2236
|
-
<Button
|
|
2237
|
-
size="sm"
|
|
2238
|
-
variant="ghost"
|
|
2239
|
-
className="h-7 gap-1 px-2 text-xs"
|
|
2240
|
-
disabled={uploading}
|
|
2241
|
-
>
|
|
2242
|
-
{uploading ? (
|
|
2243
|
-
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
|
2244
|
-
) : (
|
|
2245
|
-
<Plus className="h-3.5 w-3.5" />
|
|
2246
|
-
)}
|
|
2247
|
-
New
|
|
2248
|
-
<ChevronDown className="h-3 w-3 opacity-60" />
|
|
2249
|
-
</Button>
|
|
2250
|
-
</DropdownMenuTrigger>
|
|
2251
|
-
<DropdownMenuContent align="start" className="w-44">
|
|
2252
|
-
<DropdownMenuItem
|
|
2253
|
-
onClick={() => {
|
|
2254
|
-
setNewFileParent("");
|
|
2255
|
-
setNewFileName("");
|
|
2256
|
-
setFileCreateError(null);
|
|
2257
|
-
}}
|
|
2258
|
-
>
|
|
2259
|
-
<FilePlus className="mr-2 h-3.5 w-3.5" />
|
|
2260
|
-
New file
|
|
2261
|
-
</DropdownMenuItem>
|
|
2262
|
-
<DropdownMenuItem
|
|
2263
|
-
onClick={() => {
|
|
2264
|
-
setNewFolderParent("");
|
|
2265
|
-
setNewFolderName("");
|
|
2266
|
-
setFolderError(null);
|
|
2267
|
-
}}
|
|
2268
|
-
>
|
|
2269
|
-
<FolderPlus className="mr-2 h-3.5 w-3.5" />
|
|
2270
|
-
New folder
|
|
2271
|
-
</DropdownMenuItem>
|
|
2272
|
-
<DropdownMenuItem
|
|
2273
|
-
onClick={() => triggerUpload("")}
|
|
2274
|
-
disabled={uploading}
|
|
2275
|
-
>
|
|
2276
|
-
<Upload className="mr-2 h-3.5 w-3.5" />
|
|
2277
|
-
Upload
|
|
2278
|
-
</DropdownMenuItem>
|
|
2279
|
-
</DropdownMenuContent>
|
|
2280
|
-
</DropdownMenu>
|
|
2281
|
-
|
|
2282
|
-
<div className="flex items-center gap-0.5">
|
|
2283
|
-
<Button
|
|
2284
|
-
size="sm"
|
|
2285
|
-
variant="ghost"
|
|
2286
|
-
className="h-7 w-7 p-0"
|
|
2287
|
-
title="Refresh tree"
|
|
2288
|
-
onClick={refreshTree}
|
|
2289
|
-
disabled={refreshingTree}
|
|
2290
|
-
>
|
|
2291
|
-
{refreshingTree ? (
|
|
2292
|
-
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
|
2293
|
-
) : (
|
|
2294
|
-
<RefreshCw className="h-3.5 w-3.5" />
|
|
2295
|
-
)}
|
|
2296
|
-
</Button>
|
|
2297
|
-
<Button
|
|
2298
|
-
size="sm"
|
|
2299
|
-
variant="ghost"
|
|
2300
|
-
className="h-7 w-7 p-0"
|
|
2301
|
-
title={showHidden ? "Hide hidden files" : "Show hidden files"}
|
|
2302
|
-
onClick={() => useShowHiddenStore.getState().toggle()}
|
|
2303
|
-
>
|
|
2304
|
-
{showHidden ? (
|
|
2305
|
-
<Eye className="h-3.5 w-3.5" />
|
|
2306
|
-
) : (
|
|
2307
|
-
<EyeOff className="h-3.5 w-3.5" />
|
|
2308
|
-
)}
|
|
2309
|
-
</Button>
|
|
2310
|
-
<ThemeToggle />
|
|
2311
|
-
<Button
|
|
2312
|
-
size="sm"
|
|
2313
|
-
variant="ghost"
|
|
2314
|
-
className="h-7 w-7 p-0"
|
|
2315
|
-
title="AI Agent panel"
|
|
2316
|
-
onClick={() => {
|
|
2317
|
-
useAIPanelStore.getState().toggle();
|
|
2318
|
-
if (isMobile) setSidebarCollapsed(true);
|
|
2319
|
-
}}
|
|
2320
|
-
>
|
|
2321
|
-
<Bot className="h-3.5 w-3.5" />
|
|
2322
|
-
</Button>
|
|
2323
|
-
<Button
|
|
2324
|
-
size="sm"
|
|
2325
|
-
variant="ghost"
|
|
2326
|
-
className="h-7 w-7 p-0"
|
|
2327
|
-
title="Settings"
|
|
2328
|
-
onClick={() => setSettingsOpen(true)}
|
|
2329
|
-
>
|
|
2330
|
-
<Settings className="h-3.5 w-3.5" />
|
|
2331
|
-
</Button>
|
|
2332
|
-
</div>
|
|
2333
|
-
</div>
|
|
2334
|
-
|
|
2335
|
-
{uploadError && (
|
|
2336
|
-
<div className="flex items-center gap-1.5 px-3 py-1.5 text-xs text-destructive bg-destructive/10 shrink-0">
|
|
2337
|
-
<AlertCircle className="h-3.5 w-3.5 shrink-0" />
|
|
2338
|
-
{uploadError}
|
|
2339
|
-
</div>
|
|
2340
|
-
)}
|
|
2341
|
-
|
|
2342
|
-
{newFolderParent === "" && (
|
|
2343
|
-
<div className="flex items-center gap-1.5 px-2 py-1 border-b shrink-0">
|
|
2344
|
-
<Folder className="h-4 w-4 shrink-0 text-warning" />
|
|
2345
|
-
<input
|
|
2346
|
-
className="flex-1 bg-transparent text-sm outline-none border-b border-border min-w-0"
|
|
2347
|
-
placeholder="Folder name"
|
|
2348
|
-
value={newFolderName}
|
|
2349
|
-
onChange={(e) => setNewFolderName(e.target.value)}
|
|
2350
|
-
onKeyDown={(e) => {
|
|
2351
|
-
if (e.key === "Enter") handleCreateFolder();
|
|
2352
|
-
if (e.key === "Escape") {
|
|
2353
|
-
setNewFolderParent(null);
|
|
2354
|
-
setNewFolderName("");
|
|
2355
|
-
}
|
|
2356
|
-
}}
|
|
2357
|
-
/>
|
|
2358
|
-
{folderError && (
|
|
2359
|
-
<span className="text-xs text-destructive">{folderError}</span>
|
|
2360
|
-
)}
|
|
2361
|
-
<Button
|
|
2362
|
-
size="sm"
|
|
2363
|
-
variant="ghost"
|
|
2364
|
-
className="h-6 w-6 p-0"
|
|
2365
|
-
onClick={handleCreateFolder}
|
|
2366
|
-
>
|
|
2367
|
-
<Check className="h-3 w-3" />
|
|
2368
|
-
</Button>
|
|
2369
|
-
<Button
|
|
2370
|
-
size="sm"
|
|
2371
|
-
variant="ghost"
|
|
2372
|
-
className="h-6 w-6 p-0"
|
|
2373
|
-
onClick={() => {
|
|
2374
|
-
setNewFolderParent(null);
|
|
2375
|
-
setNewFolderName("");
|
|
2376
|
-
}}
|
|
2377
|
-
>
|
|
2378
|
-
<X className="h-3 w-3" />
|
|
2379
|
-
</Button>
|
|
2380
|
-
</div>
|
|
2381
|
-
)}
|
|
2382
|
-
|
|
2383
|
-
{newFileParent === "" && (
|
|
2384
|
-
<div className="flex items-center gap-1.5 px-2 py-1 border-b shrink-0">
|
|
2385
|
-
<FileText className="h-4 w-4 shrink-0 text-accent" />
|
|
2386
|
-
<input
|
|
2387
|
-
autoFocus
|
|
2388
|
-
className="flex-1 bg-transparent text-sm outline-none border-b border-border min-w-0"
|
|
2389
|
-
placeholder="filename (default .md)"
|
|
2390
|
-
value={newFileName}
|
|
2391
|
-
onChange={(e) => setNewFileName(e.target.value)}
|
|
2392
|
-
onKeyDown={(e) => {
|
|
2393
|
-
if (e.key === "Enter") handleCreateFile();
|
|
2394
|
-
if (e.key === "Escape") {
|
|
2395
|
-
setNewFileParent(null);
|
|
2396
|
-
setNewFileName("");
|
|
2397
|
-
}
|
|
2398
|
-
}}
|
|
2399
|
-
/>
|
|
2400
|
-
{fileCreateError && (
|
|
2401
|
-
<span className="text-xs text-destructive">{fileCreateError}</span>
|
|
2402
|
-
)}
|
|
2403
|
-
<Button
|
|
2404
|
-
size="sm"
|
|
2405
|
-
variant="ghost"
|
|
2406
|
-
className="h-6 w-6 p-0"
|
|
2407
|
-
onClick={handleCreateFile}
|
|
2408
|
-
>
|
|
2409
|
-
<Check className="h-3 w-3" />
|
|
2410
|
-
</Button>
|
|
2411
|
-
<Button
|
|
2412
|
-
size="sm"
|
|
2413
|
-
variant="ghost"
|
|
2414
|
-
className="h-6 w-6 p-0"
|
|
2415
|
-
onClick={() => {
|
|
2416
|
-
setNewFileParent(null);
|
|
2417
|
-
setNewFileName("");
|
|
2418
|
-
}}
|
|
2419
|
-
>
|
|
2420
|
-
<X className="h-3 w-3" />
|
|
2421
|
-
</Button>
|
|
2422
|
-
</div>
|
|
2423
|
-
)}
|
|
2424
|
-
|
|
2425
|
-
<div className="border-b">
|
|
2426
|
-
<SidebarSearchBox onOpenFile={openFromSearch} />
|
|
2427
|
-
</div>
|
|
2428
|
-
|
|
2429
|
-
<div
|
|
2430
|
-
ref={sidebarScrollRef}
|
|
2431
|
-
className={cn(
|
|
2432
|
-
"flex-1 overflow-auto py-1 editorial-file-tree",
|
|
2433
|
-
dragOverPath === "" &&
|
|
2434
|
-
"ring-2 ring-inset ring-primary bg-primary/5",
|
|
2435
|
-
)}
|
|
2436
|
-
onDragOver={(e) => handleDragOver(e, "", "root")}
|
|
2437
|
-
onDragLeave={(e) => {
|
|
2438
|
-
if (!e.currentTarget.contains(e.relatedTarget as Node))
|
|
2439
|
-
setDragOverPath(null);
|
|
2440
|
-
}}
|
|
2441
|
-
onDrop={(e) => handleDropOnFolder(e, "")}
|
|
2442
|
-
>
|
|
2443
|
-
{/* Pinned section */}
|
|
2444
|
-
{pins.length > 0 && (
|
|
2445
|
-
<div className="border-b mb-1">
|
|
2446
|
-
<button
|
|
2447
|
-
type="button"
|
|
2448
|
-
className="flex w-full items-center gap-1 px-2 py-1 text-[10px] font-semibold uppercase tracking-wider text-muted-foreground hover:text-foreground"
|
|
2449
|
-
onClick={() => setPinnedCollapsed((c) => !c)}
|
|
2450
|
-
>
|
|
2451
|
-
{pinnedCollapsed ? <ChevronRight className="h-3 w-3" /> : <ChevronDown className="h-3 w-3" />}
|
|
2452
|
-
<Pin className="h-3 w-3" />
|
|
2453
|
-
Pinned
|
|
2454
|
-
<span className="ml-auto text-[9px] tabular-nums opacity-60">{pins.length}</span>
|
|
2455
|
-
</button>
|
|
2456
|
-
{!pinnedCollapsed && pins.map((p) => (
|
|
2457
|
-
<div
|
|
2458
|
-
key={p.path}
|
|
2459
|
-
role="button"
|
|
2460
|
-
tabIndex={0}
|
|
2461
|
-
className={cn(
|
|
2462
|
-
"group flex items-center gap-1.5 rounded-sm px-2 py-1 text-sm cursor-pointer transition-colors select-none",
|
|
2463
|
-
openFile?.path === p.path ? "bg-accent-soft text-foreground font-medium" : "hover:bg-muted",
|
|
2464
|
-
)}
|
|
2465
|
-
onClick={() => { void openViewer({ path: p.path, name: p.name, type: (p.type ?? "file") as TreeNode["type"], modifiedAt: "" } as TreeNode); if (isMobile) setSidebarCollapsed(true); }}
|
|
2466
|
-
onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); void openViewer({ path: p.path, name: p.name, type: (p.type ?? "file") as TreeNode["type"], modifiedAt: "" } as TreeNode); } }}
|
|
2467
|
-
>
|
|
2468
|
-
<Star className="h-3 w-3 shrink-0 fill-current text-amber-400" />
|
|
2469
|
-
<span className="min-w-0 flex-1 truncate text-xs">{p.name}</span>
|
|
2470
|
-
<span className="max-w-[80px] truncate text-[10px] text-muted-foreground/60">{p.path.split("/").slice(0, -1).join("/")}</span>
|
|
2471
|
-
<button
|
|
2472
|
-
type="button"
|
|
2473
|
-
className="hover-reveal shrink-0 rounded p-0.5 text-muted-foreground/50 opacity-0 transition-colors hover:bg-muted hover:text-amber-400 group-hover:opacity-100 focus:opacity-100"
|
|
2474
|
-
title="Remove from pinned"
|
|
2475
|
-
onClick={(e) => {
|
|
2476
|
-
e.stopPropagation();
|
|
2477
|
-
usePinStore
|
|
2478
|
-
.getState()
|
|
2479
|
-
.toggle({ path: p.path, name: p.name }, activeWorkspaceId);
|
|
2480
|
-
}}
|
|
2481
|
-
>
|
|
2482
|
-
<X className="h-3 w-3" />
|
|
2483
|
-
</button>
|
|
2484
|
-
</div>
|
|
2485
|
-
))}
|
|
2486
|
-
</div>
|
|
2487
|
-
)}
|
|
2488
|
-
{/* Recent files section */}
|
|
2489
|
-
{recents.length > 0 && (
|
|
2490
|
-
<div className="border-b mb-1">
|
|
2491
|
-
<button
|
|
2492
|
-
type="button"
|
|
2493
|
-
className="flex w-full items-center gap-1 px-2 py-1 text-[10px] font-semibold uppercase tracking-wider text-muted-foreground hover:text-foreground"
|
|
2494
|
-
onClick={() => setRecentCollapsed((c) => !c)}
|
|
2495
|
-
>
|
|
2496
|
-
{recentCollapsed ? <ChevronRight className="h-3 w-3" /> : <ChevronDown className="h-3 w-3" />}
|
|
2497
|
-
Recent
|
|
2498
|
-
<span className="ml-auto text-[9px] tabular-nums opacity-60">{recents.length}</span>
|
|
2499
|
-
</button>
|
|
2500
|
-
{!recentCollapsed && recents.slice(0, 8).map((r) => (
|
|
2501
|
-
<div
|
|
2502
|
-
key={r.path}
|
|
2503
|
-
role="button"
|
|
2504
|
-
tabIndex={0}
|
|
2505
|
-
className={cn(
|
|
2506
|
-
"flex items-center gap-1.5 rounded-sm px-2 py-1 text-sm cursor-pointer transition-colors select-none",
|
|
2507
|
-
openFile?.path === r.path ? "bg-accent-soft text-foreground font-medium" : "hover:bg-muted",
|
|
2508
|
-
)}
|
|
2509
|
-
onClick={() => { void openViewer({ path: r.path, name: r.name, type: (r.type ?? "file") as TreeNode["type"], modifiedAt: "" } as TreeNode); if (isMobile) setSidebarCollapsed(true); }}
|
|
2510
|
-
onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); void openViewer({ path: r.path, name: r.name, type: (r.type ?? "file") as TreeNode["type"], modifiedAt: "" } as TreeNode); } }}
|
|
2511
|
-
>
|
|
2512
|
-
<FileText className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
|
2513
|
-
<span className="flex-1 truncate text-xs">{r.name}</span>
|
|
2514
|
-
<span className="text-[10px] text-muted-foreground/60 truncate max-w-[80px]">{r.path.split("/").slice(0, -1).join("/")}</span>
|
|
2515
|
-
</div>
|
|
2516
|
-
))}
|
|
2517
|
-
</div>
|
|
2518
|
-
)}
|
|
2519
|
-
{rootLoading ? (
|
|
2520
|
-
<div className="flex justify-center py-6">
|
|
2521
|
-
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
|
|
2522
|
-
</div>
|
|
2523
|
-
) : roots.length === 0 ? (
|
|
2524
|
-
<div className="flex flex-col items-center gap-3 px-4 py-8 text-center">
|
|
2525
|
-
<div className="rounded-full bg-muted p-3">
|
|
2526
|
-
<FileText className="h-6 w-6 text-muted-foreground" />
|
|
2527
|
-
</div>
|
|
2528
|
-
<div className="space-y-1">
|
|
2529
|
-
<p className="text-sm font-medium">No files yet</p>
|
|
2530
|
-
<p className="text-xs text-muted-foreground">
|
|
2531
|
-
Upload files or add them to the configured directory
|
|
2532
|
-
</p>
|
|
2533
|
-
</div>
|
|
2534
|
-
<Button
|
|
2535
|
-
size="sm"
|
|
2536
|
-
variant="outline"
|
|
2537
|
-
className="w-full gap-1.5 max-w-[180px]"
|
|
2538
|
-
onClick={() => triggerUpload("")}
|
|
2539
|
-
disabled={uploading}
|
|
2540
|
-
>
|
|
2541
|
-
{uploading ? (
|
|
2542
|
-
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
|
2543
|
-
) : (
|
|
2544
|
-
<Upload className="h-3.5 w-3.5" />
|
|
2545
|
-
)}
|
|
2546
|
-
Upload Files
|
|
2547
|
-
</Button>
|
|
2548
|
-
</div>
|
|
2549
|
-
) : (
|
|
2550
|
-
<FileTree
|
|
2551
|
-
ctx={treeCtx}
|
|
2552
|
-
nodes={roots}
|
|
2553
|
-
openPath={openFile?.path ?? null}
|
|
2554
|
-
dragOverPath={dragOverPath}
|
|
2555
|
-
branchDropdownNode={branchDropdownNode}
|
|
2556
|
-
branchDropdownPos={branchDropdownPos}
|
|
2557
|
-
nodeBranches={nodeBranches}
|
|
2558
|
-
branchesLoading={branchesLoading}
|
|
2559
|
-
checkingOutBranch={checkingOutBranch}
|
|
2560
|
-
pullingRepo={pullingRepo}
|
|
2561
|
-
activePaths={activePaths}
|
|
2562
|
-
pins={pins}
|
|
2563
|
-
isMobile={isMobile}
|
|
2564
|
-
activeWorkspaceId={activeWorkspaceId}
|
|
2565
|
-
newFileParent={newFileParent}
|
|
2566
|
-
newFileName={newFileName}
|
|
2567
|
-
fileCreateError={fileCreateError}
|
|
2568
|
-
newFolderParent={newFolderParent}
|
|
2569
|
-
newFolderName={newFolderName}
|
|
2570
|
-
folderError={folderError}
|
|
2571
|
-
sidebarScrollRef={sidebarScrollRef}
|
|
2572
|
-
/>
|
|
2573
|
-
)}
|
|
2574
|
-
</div>
|
|
2575
|
-
<div className="border-t px-2 py-2 bg-muted shrink-0">
|
|
2576
|
-
<DropdownMenu>
|
|
2577
|
-
<DropdownMenuTrigger asChild>
|
|
2578
|
-
<Button
|
|
2579
|
-
size="sm"
|
|
2580
|
-
variant="ghost"
|
|
2581
|
-
className="w-full h-auto justify-between gap-2 px-2 py-1.5 text-left"
|
|
2582
|
-
title={rootPath ?? ""}
|
|
2583
|
-
>
|
|
2584
|
-
<span className="flex flex-col min-w-0">
|
|
2585
|
-
<span className="truncate text-xs font-medium">
|
|
2586
|
-
{workspaces.find((w) => w.id === activeWorkspaceId)?.name ?? "Workspace"}
|
|
2587
|
-
</span>
|
|
2588
|
-
<span className="truncate text-[10px] text-muted-foreground font-mono">
|
|
2589
|
-
{rootPath ?? ""}
|
|
2590
|
-
</span>
|
|
2591
|
-
</span>
|
|
2592
|
-
<ChevronDown className="h-3.5 w-3.5 shrink-0 opacity-60" />
|
|
2593
|
-
</Button>
|
|
2594
|
-
</DropdownMenuTrigger>
|
|
2595
|
-
<DropdownMenuContent align="start" className="w-60">
|
|
2596
|
-
{workspaces.map((w) => (
|
|
2597
|
-
<DropdownMenuItem
|
|
2598
|
-
key={w.id}
|
|
2599
|
-
onClick={() => void switchWorkspace(w.id)}
|
|
2600
|
-
className={cn("gap-2", w.id === activeWorkspaceId && "font-medium")}
|
|
2601
|
-
>
|
|
2602
|
-
{w.id === activeWorkspaceId ? (
|
|
2603
|
-
<Check className="h-3.5 w-3.5 shrink-0" />
|
|
2604
|
-
) : (
|
|
2605
|
-
<span className="w-3.5 shrink-0" />
|
|
2606
|
-
)}
|
|
2607
|
-
<span className="flex flex-col min-w-0 flex-1">
|
|
2608
|
-
<span className="flex items-center gap-1.5 truncate">
|
|
2609
|
-
<span className="truncate">{w.name}</span>
|
|
2610
|
-
{w.readOnly && (
|
|
2611
|
-
<span className="inline-flex items-center gap-0.5 rounded bg-muted px-1.5 text-[10px] text-muted-foreground font-normal shrink-0">
|
|
2612
|
-
<GitBranch className="h-2.5 w-2.5" /> read-only
|
|
2613
|
-
</span>
|
|
2614
|
-
)}
|
|
2615
|
-
</span>
|
|
2616
|
-
<span className="truncate text-[10px] text-muted-foreground font-mono">{w.rootDir}</span>
|
|
2617
|
-
{w.git?.lastPulledAt && timeAgo(w.git.lastPulledAt) && (
|
|
2618
|
-
<span className="text-[10px] text-muted-foreground/70">synced {timeAgo(w.git.lastPulledAt)}</span>
|
|
2619
|
-
)}
|
|
2620
|
-
</span>
|
|
2621
|
-
{isWsAdmin && w.git && (
|
|
2622
|
-
<button
|
|
2623
|
-
className={cn(
|
|
2624
|
-
"shrink-0 rounded p-0.5 hover:bg-accent transition-colors",
|
|
2625
|
-
w.git.lastError ? "text-destructive" : "text-muted-foreground hover:text-foreground",
|
|
2626
|
-
)}
|
|
2627
|
-
title={w.git.lastError ? `Last refresh failed: ${w.git.lastError}` : "Refresh"}
|
|
2628
|
-
disabled={refreshingWsId === w.id}
|
|
2629
|
-
onClick={(e) => {
|
|
2630
|
-
e.stopPropagation();
|
|
2631
|
-
e.preventDefault();
|
|
2632
|
-
void handleRefreshWorkspace(w.id);
|
|
2633
|
-
}}
|
|
2634
|
-
>
|
|
2635
|
-
<RefreshCw className={cn("h-3.5 w-3.5", refreshingWsId === w.id && "animate-spin")} />
|
|
2636
|
-
</button>
|
|
2637
|
-
)}
|
|
2638
|
-
{isWsAdmin && (
|
|
2639
|
-
<button
|
|
2640
|
-
className="shrink-0 rounded p-0.5 text-muted-foreground hover:text-destructive hover:bg-destructive/10 transition-colors"
|
|
2641
|
-
title="Delete workspace (does not delete files)"
|
|
2642
|
-
onClick={(e) => {
|
|
2643
|
-
e.stopPropagation();
|
|
2644
|
-
setDeletingWorkspaceId(w.id);
|
|
2645
|
-
}}
|
|
2646
|
-
>
|
|
2647
|
-
<Trash2 className="h-3.5 w-3.5" />
|
|
2648
|
-
</button>
|
|
2649
|
-
)}
|
|
2650
|
-
</DropdownMenuItem>
|
|
2651
|
-
))}
|
|
2652
|
-
{isWsAdmin && (
|
|
2653
|
-
<>
|
|
2654
|
-
<DropdownMenuSeparator />
|
|
2655
|
-
<DropdownMenuItem onClick={() => setAddingWorkspace(true)}>
|
|
2656
|
-
<FolderPlus className="mr-2 h-3.5 w-3.5" />
|
|
2657
|
-
Add workspace…
|
|
2658
|
-
</DropdownMenuItem>
|
|
2659
|
-
</>
|
|
2660
|
-
)}
|
|
2661
|
-
</DropdownMenuContent>
|
|
2662
|
-
</DropdownMenu>
|
|
2663
|
-
</div>
|
|
2664
|
-
{/* Resize handle */}
|
|
2665
|
-
<div
|
|
2666
|
-
role="separator"
|
|
2667
|
-
aria-orientation="vertical"
|
|
2668
|
-
aria-label="Resize sidebar"
|
|
2669
|
-
title="Drag to resize"
|
|
2670
|
-
onMouseDown={(e) => {
|
|
2671
|
-
e.preventDefault();
|
|
2672
|
-
const startX = e.clientX;
|
|
2673
|
-
const startW = sidebarWidth;
|
|
2674
|
-
setSidebarResizing(true);
|
|
2675
|
-
const prevCursor = document.body.style.cursor;
|
|
2676
|
-
const prevSelect = document.body.style.userSelect;
|
|
2677
|
-
document.body.style.cursor = "col-resize";
|
|
2678
|
-
document.body.style.userSelect = "none";
|
|
2679
|
-
const onMove = (ev: MouseEvent) => {
|
|
2680
|
-
setSidebarWidth(startW + (ev.clientX - startX));
|
|
2681
|
-
};
|
|
2682
|
-
const onUp = () => {
|
|
2683
|
-
setSidebarResizing(false);
|
|
2684
|
-
document.body.style.cursor = prevCursor;
|
|
2685
|
-
document.body.style.userSelect = prevSelect;
|
|
2686
|
-
window.removeEventListener("mousemove", onMove);
|
|
2687
|
-
window.removeEventListener("mouseup", onUp);
|
|
2688
|
-
};
|
|
2689
|
-
window.addEventListener("mousemove", onMove);
|
|
2690
|
-
window.addEventListener("mouseup", onUp);
|
|
2691
|
-
}}
|
|
2692
|
-
onDoubleClick={() => setSidebarWidth(288)}
|
|
2693
|
-
onKeyDown={(e) => {
|
|
2694
|
-
if (e.key === "ArrowLeft") {
|
|
2695
|
-
e.preventDefault();
|
|
2696
|
-
setSidebarWidth(sidebarWidth - 16);
|
|
2697
|
-
} else if (e.key === "ArrowRight") {
|
|
2698
|
-
e.preventDefault();
|
|
2699
|
-
setSidebarWidth(sidebarWidth + 16);
|
|
2700
|
-
}
|
|
2701
|
-
}}
|
|
2702
|
-
tabIndex={0}
|
|
2703
|
-
aria-valuemin={SIDEBAR_MIN_WIDTH}
|
|
2704
|
-
aria-valuemax={SIDEBAR_MAX_WIDTH}
|
|
2705
|
-
aria-valuenow={sidebarWidth}
|
|
2706
|
-
className={cn(
|
|
2707
|
-
"absolute right-0 top-0 z-20 h-full w-1.5 cursor-col-resize -mr-px transition-colors hover:bg-primary/40 focus:bg-primary/40 focus:outline-none hidden md:block",
|
|
2708
|
-
sidebarResizing && "bg-primary/60",
|
|
2709
|
-
)}
|
|
2710
|
-
/>
|
|
2711
|
-
</Card>
|
|
2712
|
-
)}
|
|
2713
|
-
|
|
2714
|
-
{/* Right panel */}
|
|
2715
|
-
<div className="flex-1 flex flex-col min-w-0 relative">
|
|
2716
|
-
{/* Desktop: floating reopen button when sidebar is collapsed */}
|
|
2717
|
-
{sidebarCollapsed && (
|
|
2718
|
-
<Button
|
|
2719
|
-
size="sm"
|
|
2720
|
-
variant="ghost"
|
|
2721
|
-
className="hidden md:flex absolute left-2 top-2 z-10 h-7 w-7 p-0"
|
|
2722
|
-
title="Show sidebar"
|
|
2723
|
-
onClick={() => setSidebarCollapsed(false)}
|
|
2724
|
-
>
|
|
2725
|
-
<PanelLeftOpen className="h-3.5 w-3.5" />
|
|
2726
|
-
</Button>
|
|
2727
|
-
)}
|
|
2728
|
-
{/* Mobile: dedicated top bar hosting the drawer + AI panel toggles */}
|
|
2729
|
-
<div className="md:hidden flex h-11 shrink-0 items-center justify-between gap-2 border-b bg-muted px-1">
|
|
2730
|
-
<Button
|
|
2731
|
-
size="sm"
|
|
2732
|
-
variant="ghost"
|
|
2733
|
-
className="h-9 w-9 p-0"
|
|
2734
|
-
title="Show sidebar"
|
|
2735
|
-
onClick={() => {
|
|
2736
|
-
setSidebarCollapsed(false);
|
|
2737
|
-
useAIPanelStore.getState().close();
|
|
2738
|
-
}}
|
|
2739
|
-
>
|
|
2740
|
-
<PanelLeftOpen className="h-4 w-4" />
|
|
2741
|
-
</Button>
|
|
2742
|
-
<span
|
|
2743
|
-
className="flex min-w-0 items-center gap-1.5 text-xs font-semibold tracking-tight text-muted-foreground"
|
|
2744
|
-
title={openFile?.path}
|
|
2745
|
-
>
|
|
2746
|
-
{openFile && (
|
|
2747
|
-
<span className="editorial-tree-typeicon shrink-0">
|
|
2748
|
-
{openFileViewerKind === "app" || openFileViewerKind === "html" ? (
|
|
2749
|
-
<Globe className="h-4 w-4 text-foreground/70" />
|
|
2750
|
-
) : isImage(openFile.name) ? (
|
|
2751
|
-
<ImageIcon className="h-4 w-4 text-sunshine-700" />
|
|
2752
|
-
) : isText(openFile.name) ? (
|
|
2753
|
-
<FileText className="h-4 w-4 text-foreground/70" />
|
|
2754
|
-
) : (
|
|
2755
|
-
<File className="h-4 w-4 text-foreground/60" />
|
|
2756
|
-
)}
|
|
2757
|
-
</span>
|
|
2758
|
-
)}
|
|
2759
|
-
<span className="truncate">
|
|
2760
|
-
{openFile ? openFile.name : "Wiki Viewer"}
|
|
2761
|
-
</span>
|
|
2762
|
-
</span>
|
|
2763
|
-
<Button
|
|
2764
|
-
size="sm"
|
|
2765
|
-
variant="ghost"
|
|
2766
|
-
className="h-9 w-9 p-0"
|
|
2767
|
-
title="AI Agent panel"
|
|
2768
|
-
onClick={() => {
|
|
2769
|
-
useAIPanelStore.getState().open();
|
|
2770
|
-
setSidebarCollapsed(true);
|
|
2771
|
-
}}
|
|
2772
|
-
>
|
|
2773
|
-
<Bot className="h-4 w-4" />
|
|
2774
|
-
</Button>
|
|
2775
|
-
</div>
|
|
2776
|
-
{openFile ? (
|
|
2777
|
-
openFileViewerKind === "node-app" ? (
|
|
2778
|
-
<NodeAppViewer path={openFile.path} title={openFile.name} />
|
|
2779
|
-
) : (openFileViewerKind === "app" || openFileViewerKind === "html") ? (
|
|
2780
|
-
// html files → direct asset URL; app folders → index.html (default)
|
|
2781
|
-
(() => {
|
|
2782
|
-
const websiteSrc =
|
|
2783
|
-
openFileViewerKind === "html"
|
|
2784
|
-
? `/api/assets/${openFile.path}`
|
|
2785
|
-
: undefined;
|
|
2786
|
-
return appFullscreen ? (
|
|
2787
|
-
<WebsiteViewer
|
|
2788
|
-
path={openFile.path}
|
|
2789
|
-
title={openFile.name}
|
|
2790
|
-
src={websiteSrc}
|
|
2791
|
-
fullscreen
|
|
2792
|
-
onExit={() => setAppFullscreen(false)}
|
|
2793
|
-
/>
|
|
2794
|
-
) : (
|
|
2795
|
-
<div className="flex-1 flex flex-col overflow-hidden min-w-0">
|
|
2796
|
-
<div className={cn("flex items-center justify-between px-4 py-2 border-b bg-muted shrink-0 editorial-doc-header", !isMobile && sidebarCollapsed && "pl-11")}>
|
|
2797
|
-
<div className="flex items-center gap-2 min-w-0">
|
|
2798
|
-
<span className="hidden md:inline-flex">
|
|
2799
|
-
<span className="editorial-tree-typeicon">
|
|
2800
|
-
<Globe className="h-4 w-4 shrink-0 text-foreground/70" />
|
|
2801
|
-
</span>
|
|
2802
|
-
</span>
|
|
2803
|
-
<span
|
|
2804
|
-
className="hidden md:inline text-sm font-normal truncate"
|
|
2805
|
-
title={openFile.path}
|
|
2806
|
-
>
|
|
2807
|
-
{openFile.path}
|
|
2808
|
-
</span>
|
|
2809
|
-
</div>
|
|
2810
|
-
<div className="flex items-center gap-1 shrink-0">
|
|
2811
|
-
{renderCopyMenu(openFile)}
|
|
2812
|
-
{openFileViewerKind === "html" &&
|
|
2813
|
-
!editing &&
|
|
2814
|
-
fileContent !== null && (
|
|
2815
|
-
<Button
|
|
2816
|
-
size="sm"
|
|
2817
|
-
variant="ghost"
|
|
2818
|
-
className="h-7 w-7 p-0"
|
|
2819
|
-
title="Edit source"
|
|
2820
|
-
onClick={() => {
|
|
2821
|
-
setEditing(true);
|
|
2822
|
-
setEditContent(fileContent);
|
|
2823
|
-
setSaveError(null);
|
|
2824
|
-
}}
|
|
2825
|
-
>
|
|
2826
|
-
<Pencil className="h-3.5 w-3.5" />
|
|
2827
|
-
</Button>
|
|
2828
|
-
)}
|
|
2829
|
-
<Button
|
|
2830
|
-
size="sm"
|
|
2831
|
-
variant="ghost"
|
|
2832
|
-
className="h-7 w-7 p-0"
|
|
2833
|
-
title="Refresh"
|
|
2834
|
-
onClick={() => setAppKey((k) => k + 1)}
|
|
2835
|
-
>
|
|
2836
|
-
<RefreshCw className="h-3.5 w-3.5" />
|
|
2837
|
-
</Button>
|
|
2838
|
-
<Button
|
|
2839
|
-
size="sm"
|
|
2840
|
-
variant="ghost"
|
|
2841
|
-
className="h-7 gap-1.5 text-xs"
|
|
2842
|
-
onClick={() => setAppFullscreen(true)}
|
|
2843
|
-
>
|
|
2844
|
-
<Maximize2 className="h-3.5 w-3.5" />
|
|
2845
|
-
Open fullscreen
|
|
2846
|
-
</Button>
|
|
2847
|
-
<Button
|
|
2848
|
-
size="sm"
|
|
2849
|
-
variant="ghost"
|
|
2850
|
-
className="h-7 w-7 p-0"
|
|
2851
|
-
onClick={() => {
|
|
2852
|
-
setOpenFile(null);
|
|
2853
|
-
setEditing(false);
|
|
2854
|
-
}}
|
|
2855
|
-
>
|
|
2856
|
-
<X className="h-3.5 w-3.5" />
|
|
2857
|
-
</Button>
|
|
2858
|
-
</div>
|
|
2859
|
-
</div>
|
|
2860
|
-
{editing && openFileViewerKind === "html" ? (
|
|
2861
|
-
<div className="flex-1 flex flex-col overflow-hidden min-h-0">
|
|
2862
|
-
<textarea
|
|
2863
|
-
value={editContent}
|
|
2864
|
-
onChange={(e) => setEditContent(e.target.value)}
|
|
2865
|
-
spellCheck={false}
|
|
2866
|
-
className="flex-1 w-full min-h-0 resize-none bg-background text-foreground px-4 py-3 font-mono text-[13px] leading-relaxed outline-none border-0"
|
|
2867
|
-
/>
|
|
2868
|
-
<div className="border-t px-4 py-2 flex items-center justify-end gap-2 bg-muted shrink-0">
|
|
2869
|
-
{saveError && (
|
|
2870
|
-
<span className="text-xs text-destructive mr-auto">
|
|
2871
|
-
{saveError}
|
|
2872
|
-
</span>
|
|
2873
|
-
)}
|
|
2874
|
-
<Button
|
|
2875
|
-
size="sm"
|
|
2876
|
-
variant="ghost"
|
|
2877
|
-
onClick={() => {
|
|
2878
|
-
setEditing(false);
|
|
2879
|
-
setSaveError(null);
|
|
2880
|
-
}}
|
|
2881
|
-
>
|
|
2882
|
-
Cancel
|
|
2883
|
-
</Button>
|
|
2884
|
-
<Button
|
|
2885
|
-
size="sm"
|
|
2886
|
-
className="gap-1"
|
|
2887
|
-
onClick={handleSave}
|
|
2888
|
-
disabled={saving}
|
|
2889
|
-
>
|
|
2890
|
-
{saving && (
|
|
2891
|
-
<Loader2 className="h-3 w-3 animate-spin" />
|
|
2892
|
-
)}
|
|
2893
|
-
Save
|
|
2894
|
-
</Button>
|
|
2895
|
-
</div>
|
|
2896
|
-
</div>
|
|
2897
|
-
) : (
|
|
2898
|
-
<WebsiteViewer
|
|
2899
|
-
key={appKey}
|
|
2900
|
-
path={openFile.path}
|
|
2901
|
-
title={openFile.name}
|
|
2902
|
-
src={websiteSrc}
|
|
2903
|
-
/>
|
|
2904
|
-
)}
|
|
2905
|
-
</div>
|
|
2906
|
-
);
|
|
2907
|
-
})()
|
|
2908
|
-
) : (
|
|
2909
|
-
<div className="flex-1 flex flex-col overflow-hidden min-w-0">
|
|
2910
|
-
<div className={cn("flex items-center justify-between px-4 py-2 border-b bg-muted shrink-0 editorial-doc-header", !isMobile && sidebarCollapsed && "pl-11")}>
|
|
2911
|
-
<div className="flex items-center gap-2 min-w-0">
|
|
2912
|
-
<span className="hidden md:inline-flex"><span className="editorial-tree-typeicon">
|
|
2913
|
-
{isImage(openFile.name) ? (
|
|
2914
|
-
<ImageIcon className="h-4 w-4 shrink-0 text-sunshine-700" />
|
|
2915
|
-
) : isText(openFile.name) ? (
|
|
2916
|
-
<FileText className="h-4 w-4 shrink-0 text-foreground/70" />
|
|
2917
|
-
) : (
|
|
2918
|
-
<File className="h-4 w-4 shrink-0 text-foreground/60" />
|
|
2919
|
-
)}
|
|
2920
|
-
</span></span>
|
|
2921
|
-
<span
|
|
2922
|
-
className="hidden md:inline text-sm font-normal truncate"
|
|
2923
|
-
title={openFile.path}
|
|
2924
|
-
>
|
|
2925
|
-
{openFile.path}
|
|
2926
|
-
</span>
|
|
2927
|
-
{gitFileInfo && (
|
|
2928
|
-
<span className="hidden md:flex items-center gap-1 text-[11px] text-muted-foreground shrink-0 ml-1">
|
|
2929
|
-
<User className="h-3 w-3 shrink-0" />
|
|
2930
|
-
<span className="truncate max-w-[100px]">{gitFileInfo.author}</span>
|
|
2931
|
-
<span title={new Date(gitFileInfo.date).toLocaleString()} className="shrink-0">{timeAgo(gitFileInfo.date)}</span>
|
|
2932
|
-
</span>
|
|
2933
|
-
)}
|
|
2934
|
-
</div>
|
|
2935
|
-
<div className="flex items-center gap-1 shrink-0">
|
|
2936
|
-
{renderCopyMenu(openFile)}
|
|
2937
|
-
<Button
|
|
2938
|
-
size="sm"
|
|
2939
|
-
variant="ghost"
|
|
2940
|
-
className="h-7 w-7 p-0"
|
|
2941
|
-
title="File history"
|
|
2942
|
-
onClick={() => { if (showHistory) setShowHistory(false); else void loadHistory(); }}
|
|
2943
|
-
>
|
|
2944
|
-
<History className="h-3.5 w-3.5" />
|
|
2945
|
-
</Button>
|
|
2946
|
-
<Button
|
|
2947
|
-
size="sm"
|
|
2948
|
-
variant="ghost"
|
|
2949
|
-
className="h-7 w-7 p-0"
|
|
2950
|
-
title="Share"
|
|
2951
|
-
onClick={() => setShareDialogOpen(true)}
|
|
2952
|
-
>
|
|
2953
|
-
<Share className="h-3.5 w-3.5" />
|
|
2954
|
-
</Button>
|
|
2955
|
-
{isText(openFile.name) &&
|
|
2956
|
-
!editing &&
|
|
2957
|
-
(fileContent !== null || isMarkdown(openFile.name)) && (
|
|
2958
|
-
<Button
|
|
2959
|
-
size="sm"
|
|
2960
|
-
variant="ghost"
|
|
2961
|
-
className="h-7 w-7 p-0"
|
|
2962
|
-
onClick={() => {
|
|
2963
|
-
setEditing(true);
|
|
2964
|
-
setEditContent(fileContent ?? "");
|
|
2965
|
-
setSaveError(null);
|
|
2966
|
-
if (isMarkdown(openFile.name)) {
|
|
2967
|
-
void useEditorStore
|
|
2968
|
-
.getState()
|
|
2969
|
-
.loadPage(openFile.path);
|
|
2970
|
-
}
|
|
2971
|
-
}}
|
|
2972
|
-
>
|
|
2973
|
-
<Pencil className="h-3.5 w-3.5" />
|
|
2974
|
-
</Button>
|
|
2975
|
-
)}
|
|
2976
|
-
{isText(openFile.name) &&
|
|
2977
|
-
editing &&
|
|
2978
|
-
isMarkdown(openFile.name) && (
|
|
2979
|
-
<Button
|
|
2980
|
-
size="sm"
|
|
2981
|
-
variant="ghost"
|
|
2982
|
-
className="h-7 w-7 p-0"
|
|
2983
|
-
title="Done editing"
|
|
2984
|
-
onClick={() => {
|
|
2985
|
-
setEditing(false);
|
|
2986
|
-
setSaveError(null);
|
|
2987
|
-
}}
|
|
2988
|
-
>
|
|
2989
|
-
<Eye className="h-3.5 w-3.5" />
|
|
2990
|
-
</Button>
|
|
2991
|
-
)}
|
|
2992
|
-
{isText(openFile.name) &&
|
|
2993
|
-
!editing &&
|
|
2994
|
-
(fileContent !== null || isMarkdown(openFile.name)) && (
|
|
2995
|
-
<Button
|
|
2996
|
-
size="sm"
|
|
2997
|
-
variant="ghost"
|
|
2998
|
-
className="h-7 w-7 p-0"
|
|
2999
|
-
title="Refresh"
|
|
3000
|
-
onClick={refreshViewer}
|
|
3001
|
-
disabled={fileLoading}
|
|
3002
|
-
>
|
|
3003
|
-
{fileLoading ? (
|
|
3004
|
-
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
|
3005
|
-
) : (
|
|
3006
|
-
<RefreshCw className="h-3.5 w-3.5" />
|
|
3007
|
-
)}
|
|
3008
|
-
</Button>
|
|
3009
|
-
)}
|
|
3010
|
-
{widthAwareViewer && <ViewWidthToggle />}
|
|
3011
|
-
<Button
|
|
3012
|
-
size="sm"
|
|
3013
|
-
variant="ghost"
|
|
3014
|
-
className="h-7 w-7 p-0"
|
|
3015
|
-
onClick={() => {
|
|
3016
|
-
setOpenFile(null);
|
|
3017
|
-
setFileContent(null);
|
|
3018
|
-
setEditing(false);
|
|
3019
|
-
}}
|
|
3020
|
-
>
|
|
3021
|
-
<X className="h-3.5 w-3.5" />
|
|
3022
|
-
</Button>
|
|
3023
|
-
</div>
|
|
3024
|
-
</div>
|
|
3025
|
-
|
|
3026
|
-
{/* History panel */}
|
|
3027
|
-
{showHistory && (
|
|
3028
|
-
<div className="border-b bg-muted/30 shrink-0 max-h-[40vh] overflow-auto">
|
|
3029
|
-
<div className="flex items-center justify-between px-4 py-1.5 border-b">
|
|
3030
|
-
<span className="text-xs font-semibold text-muted-foreground">History</span>
|
|
3031
|
-
<button type="button" className="text-muted-foreground hover:text-foreground" onClick={() => setShowHistory(false)}>
|
|
3032
|
-
<X className="h-3.5 w-3.5" />
|
|
3033
|
-
</button>
|
|
3034
|
-
</div>
|
|
3035
|
-
{historyLoading ? (
|
|
3036
|
-
<div className="flex justify-center py-4">
|
|
3037
|
-
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
|
|
3038
|
-
</div>
|
|
3039
|
-
) : historyCommits.length === 0 ? (
|
|
3040
|
-
<p className="px-4 py-3 text-xs text-muted-foreground">No history found.</p>
|
|
3041
|
-
) : (
|
|
3042
|
-
<div>
|
|
3043
|
-
{historyCommits.map((c) => (
|
|
3044
|
-
<div key={c.sha}>
|
|
3045
|
-
<button
|
|
3046
|
-
type="button"
|
|
3047
|
-
className={cn(
|
|
3048
|
-
"w-full text-left px-4 py-2 hover:bg-muted transition-colors",
|
|
3049
|
-
selectedDiffSha === c.sha && "bg-muted",
|
|
3050
|
-
)}
|
|
3051
|
-
onClick={() => void selectDiff(c.sha)}
|
|
3052
|
-
>
|
|
3053
|
-
<div className="flex items-center gap-2">
|
|
3054
|
-
<code className="text-[11px] font-mono text-muted-foreground shrink-0">{c.shortSha}</code>
|
|
3055
|
-
<span className="flex-1 truncate text-xs">{c.message}</span>
|
|
3056
|
-
<span className="shrink-0 text-[11px] text-muted-foreground">{c.author}</span>
|
|
3057
|
-
<span className="shrink-0 text-[11px] text-muted-foreground" title={new Date(c.date).toLocaleString()}>{timeAgo(c.date)}</span>
|
|
3058
|
-
</div>
|
|
3059
|
-
</button>
|
|
3060
|
-
{selectedDiffSha === c.sha && (
|
|
3061
|
-
<div className="border-t">
|
|
3062
|
-
{diffLoading ? (
|
|
3063
|
-
<div className="flex justify-center py-3">
|
|
3064
|
-
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
|
|
3065
|
-
</div>
|
|
3066
|
-
) : diffContent !== null ? (
|
|
3067
|
-
<pre className="overflow-auto px-4 py-2 text-[11px] font-mono leading-relaxed whitespace-pre text-foreground/80 max-h-60">{diffContent}</pre>
|
|
3068
|
-
) : null}
|
|
3069
|
-
</div>
|
|
3070
|
-
)}
|
|
3071
|
-
</div>
|
|
3072
|
-
))}
|
|
3073
|
-
</div>
|
|
3074
|
-
)}
|
|
3075
|
-
</div>
|
|
3076
|
-
)}
|
|
3077
|
-
|
|
3078
|
-
{showLargeFileGate ? (
|
|
3079
|
-
<LargeFileGate
|
|
3080
|
-
path={openFile.path}
|
|
3081
|
-
size={openFile.size ?? 0}
|
|
3082
|
-
onOpen={() => setGateBypassPath(openFile.path)}
|
|
3083
|
-
/>
|
|
3084
|
-
) : editing ? (
|
|
3085
|
-
<div className="flex-1 flex flex-col overflow-hidden min-h-0">
|
|
3086
|
-
{isMarkdown(openFile.name) ? (
|
|
3087
|
-
<KBEditor />
|
|
3088
|
-
) : (
|
|
3089
|
-
<textarea
|
|
3090
|
-
value={editContent}
|
|
3091
|
-
onChange={(e) => setEditContent(e.target.value)}
|
|
3092
|
-
spellCheck={false}
|
|
3093
|
-
className="flex-1 w-full min-h-0 resize-none bg-background text-foreground px-4 py-3 font-mono text-[13px] leading-relaxed outline-none border-0"
|
|
3094
|
-
/>
|
|
3095
|
-
)}
|
|
3096
|
-
</div>
|
|
3097
|
-
) : isMarkdown(openFile.name) ? (
|
|
3098
|
-
<div className="flex-1 flex flex-col overflow-hidden min-h-0">
|
|
3099
|
-
{fileLoading ? (
|
|
3100
|
-
<div className="flex justify-center py-8">
|
|
3101
|
-
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
|
|
3102
|
-
</div>
|
|
3103
|
-
) : (
|
|
3104
|
-
<KBEditor mode="viewing" />
|
|
3105
|
-
)}
|
|
3106
|
-
</div>
|
|
3107
|
-
) : (
|
|
3108
|
-
<div className="flex-1 overflow-auto p-4 min-h-0">
|
|
3109
|
-
<div className={cn("w-full", contentAlignClass, contentWidthClass)}>
|
|
3110
|
-
{fileLoading ? (
|
|
3111
|
-
<div className="flex justify-center py-8">
|
|
3112
|
-
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
|
|
3113
|
-
</div>
|
|
3114
|
-
) : openFileViewerKind === "csv" ? (
|
|
3115
|
-
<CsvViewer path={openFile.path} title={openFile.name} />
|
|
3116
|
-
) : openFileViewerKind === "pdf" ? (
|
|
3117
|
-
<PdfViewer path={openFile.path} title={openFile.name} />
|
|
3118
|
-
) : openFileViewerKind === "mermaid" ? (
|
|
3119
|
-
<MermaidViewer
|
|
3120
|
-
path={openFile.path}
|
|
3121
|
-
title={openFile.name}
|
|
3122
|
-
/>
|
|
3123
|
-
) : openFileViewerKind === "notebook" ? (
|
|
3124
|
-
<NotebookViewer
|
|
3125
|
-
path={openFile.path}
|
|
3126
|
-
title={openFile.name}
|
|
3127
|
-
/>
|
|
3128
|
-
) : openFileViewerKind === "image" ? (
|
|
3129
|
-
<ImageViewer path={openFile.path} title={openFile.name} />
|
|
3130
|
-
) : openFileViewerKind === "media" ? (
|
|
3131
|
-
<MediaViewer
|
|
3132
|
-
path={openFile.path}
|
|
3133
|
-
title={openFile.name}
|
|
3134
|
-
type={
|
|
3135
|
-
["mp4", "webm", "mov", "m4v"].includes(
|
|
3136
|
-
ext(openFile.name),
|
|
3137
|
-
)
|
|
3138
|
-
? "video"
|
|
3139
|
-
: "audio"
|
|
3140
|
-
}
|
|
3141
|
-
/>
|
|
3142
|
-
) : openFileViewerKind === "docx" ? (
|
|
3143
|
-
<DocxViewer path={openFile.path} title={openFile.name} />
|
|
3144
|
-
) : openFileViewerKind === "xlsx" ? (
|
|
3145
|
-
<XlsxViewer path={openFile.path} title={openFile.name} />
|
|
3146
|
-
) : openFileViewerKind === "pptx" ? (
|
|
3147
|
-
<PptxViewer path={openFile.path} title={openFile.name} />
|
|
3148
|
-
) : openFileViewerKind === "source" ? (
|
|
3149
|
-
<SourceViewer
|
|
3150
|
-
path={openFile.path}
|
|
3151
|
-
title={openFile.name}
|
|
3152
|
-
/>
|
|
3153
|
-
) : openFileViewerKind === "fallback" ? (
|
|
3154
|
-
<FileFallbackViewer
|
|
3155
|
-
path={openFile.path}
|
|
3156
|
-
title={openFile.name}
|
|
3157
|
-
/>
|
|
3158
|
-
) : fileContent !== null ? (
|
|
3159
|
-
<pre className="text-xs font-mono whitespace-pre-wrap break-words leading-relaxed">
|
|
3160
|
-
{fileContent}
|
|
3161
|
-
</pre>
|
|
3162
|
-
) : isText(openFile.name) ? (
|
|
3163
|
-
<p className="text-sm text-muted-foreground">
|
|
3164
|
-
Could not load file.
|
|
3165
|
-
</p>
|
|
3166
|
-
) : (
|
|
3167
|
-
<p className="text-sm text-muted-foreground">
|
|
3168
|
-
Preview not available for this file type.
|
|
3169
|
-
</p>
|
|
3170
|
-
)}
|
|
3171
|
-
</div>
|
|
3172
|
-
</div>
|
|
3173
|
-
)}
|
|
3174
|
-
|
|
3175
|
-
{editing && !isMarkdown(openFile.name) && (
|
|
3176
|
-
<div className="border-t px-4 py-2 flex items-center justify-end gap-2 bg-muted shrink-0">
|
|
3177
|
-
{saveError && (
|
|
3178
|
-
<span className="text-xs text-destructive mr-auto">
|
|
3179
|
-
{saveError}
|
|
3180
|
-
</span>
|
|
3181
|
-
)}
|
|
3182
|
-
<Button
|
|
3183
|
-
size="sm"
|
|
3184
|
-
variant="ghost"
|
|
3185
|
-
onClick={() => {
|
|
3186
|
-
setEditing(false);
|
|
3187
|
-
setSaveError(null);
|
|
3188
|
-
}}
|
|
3189
|
-
>
|
|
3190
|
-
Cancel
|
|
3191
|
-
</Button>
|
|
3192
|
-
<Button
|
|
3193
|
-
size="sm"
|
|
3194
|
-
className="gap-1"
|
|
3195
|
-
onClick={handleSave}
|
|
3196
|
-
disabled={saving}
|
|
3197
|
-
>
|
|
3198
|
-
{saving && <Loader2 className="h-3 w-3 animate-spin" />}
|
|
3199
|
-
Save
|
|
3200
|
-
</Button>
|
|
3201
|
-
</div>
|
|
3202
|
-
)}
|
|
3203
|
-
</div>
|
|
3204
|
-
)
|
|
3205
|
-
) : (
|
|
3206
|
-
<div className="flex-1 flex flex-col items-center justify-center">
|
|
3207
|
-
<div className="flex flex-col items-center gap-2 text-center px-4">
|
|
3208
|
-
<FileText className="h-8 w-8 text-muted-foreground" />
|
|
3209
|
-
<p className="text-sm text-muted-foreground">
|
|
3210
|
-
Select a file to view or edit
|
|
3211
|
-
</p>
|
|
3212
|
-
</div>
|
|
3213
|
-
</div>
|
|
3214
|
-
)}
|
|
3215
|
-
</div>
|
|
3216
|
-
|
|
3217
|
-
<ShareDialog
|
|
3218
|
-
open={shareDialogOpen}
|
|
3219
|
-
onOpenChange={setShareDialogOpen}
|
|
3220
|
-
filePath={openFile?.path ?? ""}
|
|
3221
|
-
/>
|
|
3222
|
-
<AuthSettingsSheet open={settingsOpen} onOpenChange={setSettingsOpen} />
|
|
3223
|
-
<AIPanel currentPath={openFile?.path} />
|
|
3224
|
-
<input
|
|
3225
|
-
ref={fileInputRef}
|
|
3226
|
-
type="file"
|
|
3227
|
-
multiple
|
|
3228
|
-
className="hidden"
|
|
3229
|
-
onChange={(e) => {
|
|
3230
|
-
if (e.target.files) doUpload(e.target.files, uploadDirRef.current);
|
|
3231
|
-
}}
|
|
3232
|
-
/>
|
|
3233
|
-
|
|
3234
|
-
<ConfirmDialog
|
|
3235
|
-
open={!!deletingPath}
|
|
3236
|
-
onOpenChange={(open) => {
|
|
3237
|
-
if (!open) setDeletingPath(null);
|
|
3238
|
-
}}
|
|
3239
|
-
title={deletingIsDir ? "Delete folder?" : "Delete file?"}
|
|
3240
|
-
description={
|
|
3241
|
-
deletingIsDir
|
|
3242
|
-
? `"${deletingPath?.split("/").pop()}" and all its contents will be permanently deleted.`
|
|
3243
|
-
: `"${deletingPath?.split("/").pop()}" will be permanently removed.`
|
|
3244
|
-
}
|
|
3245
|
-
onConfirm={handleDelete}
|
|
3246
|
-
/>
|
|
3247
|
-
|
|
3248
|
-
<ConfirmDialog
|
|
3249
|
-
open={!!deletingWorkspaceId}
|
|
3250
|
-
onOpenChange={(open) => {
|
|
3251
|
-
if (!open) setDeletingWorkspaceId(null);
|
|
3252
|
-
}}
|
|
3253
|
-
title="Delete workspace?"
|
|
3254
|
-
description={`"${workspaces.find((w) => w.id === deletingWorkspaceId)?.name ?? ""}" will be removed from the workspace list. Files on disk are NOT deleted.`}
|
|
3255
|
-
onConfirm={handleDeleteWorkspace}
|
|
3256
|
-
/>
|
|
3257
|
-
</>}
|
|
3258
|
-
</div>
|
|
3259
|
-
);
|
|
3260
|
-
}
|