khal-os 1.260324.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/.env.example +23 -0
- package/.genie/mailbox/cli-sent.jsonl +3 -0
- package/.genie/mailbox/ds1-wave2-engineer-1.json +15 -0
- package/.genie/mailbox/ds1-wave2-engineer-2.json +15 -0
- package/.genie/mailbox/ds1-wave2-engineer-3.json +15 -0
- package/.genie/state/os-observability.json +39 -0
- package/.genie/state/tmux-control-mode-terminal.json +28 -0
- package/.genie/wishes/genieos-one-theme/WISH.md +417 -0
- package/.genie/wishes/workos-prod-rbac/WISH.md +345 -0
- package/.github/workflows/ci.yml +39 -0
- package/.github/workflows/release.yml +78 -0
- package/.github/workflows/version.yml +122 -0
- package/.husky/pre-commit +1 -0
- package/.pnpm-approve-builds.json +1 -0
- package/CLAUDE.md +117 -0
- package/LICENSE +21 -0
- package/README.md +38 -0
- package/biome.json +124 -0
- package/bun.lock +1249 -0
- package/docs/workos-setup.md +116 -0
- package/ecosystem.config.cjs +26 -0
- package/instrumentation.ts +8 -0
- package/knip.json +35 -0
- package/nats.conf +7 -0
- package/next.config.ts +25 -0
- package/package.json +78 -0
- package/packages/dev3000-app/components.ts +12 -0
- package/packages/dev3000-app/manifest.ts +19 -0
- package/packages/dev3000-app/package.json +23 -0
- package/packages/dev3000-app/views/dev3000/Dev3000App.tsx +758 -0
- package/packages/dev3000-app/views/dev3000/ErrorsPanel.tsx +160 -0
- package/packages/dev3000-app/views/dev3000/dev3000-context.tsx +21 -0
- package/packages/dev3000-app/views/dev3000/index.ts +4 -0
- package/packages/dev3000-app/views/dev3000/schema.ts +55 -0
- package/packages/dev3000-app/views/dev3000/service/index.ts +358 -0
- package/packages/dev3000-app/views/dev3000/service/runtime +1 -0
- package/packages/dev3000-app/views/dev3000/subjects.ts +9 -0
- package/packages/dev3000-app/views/dev3000/types.ts +77 -0
- package/packages/files-app/components.ts +12 -0
- package/packages/files-app/manifest.ts +19 -0
- package/packages/files-app/package.json +23 -0
- package/packages/files-app/views/files/ContextMenu.tsx +151 -0
- package/packages/files-app/views/files/DeleteConfirmDialog.tsx +39 -0
- package/packages/files-app/views/files/FileItem.tsx +128 -0
- package/packages/files-app/views/files/FilesApp.tsx +509 -0
- package/packages/files-app/views/files/FilesListView.tsx +201 -0
- package/packages/files-app/views/files/FilesToolbar.tsx +117 -0
- package/packages/files-app/views/files/GridView.tsx +90 -0
- package/packages/files-app/views/files/InlineInput.tsx +131 -0
- package/packages/files-app/views/files/UploadOverlay.tsx +61 -0
- package/packages/files-app/views/files/schema.ts +49 -0
- package/packages/files-app/views/files/service/index.ts +184 -0
- package/packages/files-app/views/files/service/runtime +1 -0
- package/packages/files-app/views/files/use-files.ts +201 -0
- package/packages/files-app/views/files/use-upload.ts +105 -0
- package/packages/genie-app/components.ts +12 -0
- package/packages/genie-app/lib/subjects.ts +87 -0
- package/packages/genie-app/manifest.ts +19 -0
- package/packages/genie-app/package.json +29 -0
- package/packages/genie-app/views/genie/service/agent-lifecycle.ts +136 -0
- package/packages/genie-app/views/genie/service/cli.ts +114 -0
- package/packages/genie-app/views/genie/service/comms.ts +141 -0
- package/packages/genie-app/views/genie/service/directory.ts +167 -0
- package/packages/genie-app/views/genie/service/index.ts +219 -0
- package/packages/genie-app/views/genie/service/system.ts +123 -0
- package/packages/genie-app/views/genie/service/teams.ts +191 -0
- package/packages/genie-app/views/genie/service/terminal-proxy.ts +184 -0
- package/packages/genie-app/views/genie/service/tmux-control.ts +318 -0
- package/packages/genie-app/views/genie/service/wishes.ts +270 -0
- package/packages/genie-app/views/genie/ui/GenieApp.tsx +5 -0
- package/packages/genie-app/views/genie/ui/PaneCard.tsx +307 -0
- package/packages/genie-app/views/genie/ui/Sidebar.tsx +212 -0
- package/packages/genie-app/views/genie/ui/TabBar.tsx +70 -0
- package/packages/genie-app/views/genie/ui/WorkspaceCanvas.tsx +343 -0
- package/packages/genie-app/views/genie/ui/XTermPane.tsx +306 -0
- package/packages/genie-app/views/genie/ui/hooks/useNatsAction.ts +54 -0
- package/packages/genie-app/views/genie/ui/hooks/useNatsRequest.ts +68 -0
- package/packages/genie-app/views/genie/ui/panels/AgentsPanel.tsx +399 -0
- package/packages/genie-app/views/genie/ui/panels/ChatPanel.tsx +351 -0
- package/packages/genie-app/views/genie/ui/panels/SystemPanel.tsx +195 -0
- package/packages/genie-app/views/genie/ui/panels/TeamsPanel.tsx +560 -0
- package/packages/genie-app/views/genie/ui/panels/WishesPanel.tsx +424 -0
- package/packages/nats-viewer-app/components.ts +12 -0
- package/packages/nats-viewer-app/manifest.ts +18 -0
- package/packages/nats-viewer-app/package.json +14 -0
- package/packages/nats-viewer-app/views/nats-viewer/ActiveSubs.tsx +34 -0
- package/packages/nats-viewer-app/views/nats-viewer/MessageLog.tsx +247 -0
- package/packages/nats-viewer-app/views/nats-viewer/NatsViewer.tsx +209 -0
- package/packages/nats-viewer-app/views/nats-viewer/PublishPanel.tsx +111 -0
- package/packages/nats-viewer-app/views/nats-viewer/RequestPanel.tsx +165 -0
- package/packages/nats-viewer-app/views/nats-viewer/Sidebar.tsx +59 -0
- package/packages/nats-viewer-app/views/nats-viewer/SubjectCatalog.tsx +63 -0
- package/packages/nats-viewer-app/views/nats-viewer/SubscribeInput.tsx +59 -0
- package/packages/nats-viewer-app/views/nats-viewer/index.ts +5 -0
- package/packages/nats-viewer-app/views/nats-viewer/nats-viewer-context.tsx +31 -0
- package/packages/nats-viewer-app/views/nats-viewer/types.ts +7 -0
- package/packages/nats-viewer-app/views/nats-viewer/use-message-buffer.ts +55 -0
- package/packages/os-cli/package.json +18 -0
- package/packages/os-cli/src/commands/events.ts +176 -0
- package/packages/os-cli/src/commands/logs.ts +96 -0
- package/packages/os-cli/src/commands/status.ts +53 -0
- package/packages/os-cli/src/commands/traces.ts +115 -0
- package/packages/os-cli/src/index.ts +15 -0
- package/packages/os-cli/src/lib/formatter.ts +123 -0
- package/packages/os-cli/src/lib/nats.ts +16 -0
- package/packages/os-cli/src/lib/trace-tree.ts +144 -0
- package/packages/os-cli/tsconfig.json +12 -0
- package/packages/os-sdk/package.json +27 -0
- package/packages/os-sdk/src/api/handler.ts +67 -0
- package/packages/os-sdk/src/config.ts +68 -0
- package/packages/os-sdk/src/db/factory.test.ts +42 -0
- package/packages/os-sdk/src/db/factory.ts +72 -0
- package/packages/os-sdk/src/db/migrate.ts +140 -0
- package/packages/os-sdk/src/db/provision.ts +44 -0
- package/packages/os-sdk/src/index.ts +36 -0
- package/packages/os-sdk/src/service/console-intercept.ts +60 -0
- package/packages/os-sdk/src/service/logger.ts +88 -0
- package/packages/os-sdk/src/service/o11y-streams.ts +88 -0
- package/packages/os-sdk/src/service/runtime.ts +259 -0
- package/packages/os-sdk/src/service/trace.ts +71 -0
- package/packages/os-sdk/tsconfig.json +16 -0
- package/packages/os-ui/package.json +13 -0
- package/packages/os-ui/src/index.ts +29 -0
- package/packages/os-ui/src/server.ts +4 -0
- package/packages/os-ui/tsconfig.json +19 -0
- package/packages/settings-app/components.ts +12 -0
- package/packages/settings-app/manifest.ts +18 -0
- package/packages/settings-app/package.json +14 -0
- package/packages/settings-app/views/settings/Settings.tsx +492 -0
- package/packages/terminal-app/components.ts +12 -0
- package/packages/terminal-app/manifest.ts +20 -0
- package/packages/terminal-app/package.json +23 -0
- package/packages/terminal-app/views/terminal/schema.ts +82 -0
- package/packages/terminal-app/views/terminal/service/index.ts +133 -0
- package/packages/terminal-app/views/terminal/service/runtime +1 -0
- package/packages/terminal-app/views/terminal/service/session.ts +290 -0
- package/packages/terminal-app/views/terminal/service/shell-hooks/bashrc-hook.sh +21 -0
- package/packages/terminal-app/views/terminal/types.ts +26 -0
- package/packages/terminal-app/views/terminal/ui/MultiTerminalApp.tsx +615 -0
- package/packages/terminal-app/views/terminal/ui/SplitDragHandle.tsx +91 -0
- package/packages/terminal-app/views/terminal/ui/SplitPaneRenderer.tsx +112 -0
- package/packages/terminal-app/views/terminal/ui/TerminalPane.tsx +478 -0
- package/packages/terminal-app/views/terminal/ui/TerminalTabBar.tsx +131 -0
- package/pnpm-workspace.yaml +9 -0
- package/postcss.config.mjs +7 -0
- package/public/file.svg +1 -0
- package/public/globe.svg +1 -0
- package/public/icons/code-server.svg +6 -0
- package/public/icons/default.svg +5 -0
- package/public/icons/dusk/1password.svg +1 -0
- package/public/icons/dusk/activity_monitor.svg +1 -0
- package/public/icons/dusk/app_store.svg +1 -0
- package/public/icons/dusk/atom.svg +1 -0
- package/public/icons/dusk/brave.svg +1 -0
- package/public/icons/dusk/calculator.svg +1 -0
- package/public/icons/dusk/calendar.svg +1 -0
- package/public/icons/dusk/chrome.svg +1 -0
- package/public/icons/dusk/chrome2.svg +1 -0
- package/public/icons/dusk/dashboard.svg +13 -0
- package/public/icons/dusk/discord.svg +1 -0
- package/public/icons/dusk/dropbox.svg +1 -0
- package/public/icons/dusk/electron.svg +1 -0
- package/public/icons/dusk/figma.svg +1 -0
- package/public/icons/dusk/finder.svg +1 -0
- package/public/icons/dusk/finder2.svg +1 -0
- package/public/icons/dusk/finder3.svg +1 -0
- package/public/icons/dusk/firefox.svg +1 -0
- package/public/icons/dusk/framer.svg +1 -0
- package/public/icons/dusk/gimp.svg +1 -0
- package/public/icons/dusk/github_desktop.svg +1 -0
- package/public/icons/dusk/hyper.svg +1 -0
- package/public/icons/dusk/hyper3.svg +1 -0
- package/public/icons/dusk/intellij.svg +1 -0
- package/public/icons/dusk/iterm2.svg +1 -0
- package/public/icons/dusk/itunes.svg +1 -0
- package/public/icons/dusk/mail.svg +1 -0
- package/public/icons/dusk/messenger.svg +1 -0
- package/public/icons/dusk/mongodb.svg +1 -0
- package/public/icons/dusk/notes.svg +1 -0
- package/public/icons/dusk/notion.svg +1 -0
- package/public/icons/dusk/obs.svg +1 -0
- package/public/icons/dusk/pages.svg +1 -0
- package/public/icons/dusk/photos.svg +1 -0
- package/public/icons/dusk/postman.svg +1 -0
- package/public/icons/dusk/preview.svg +1 -0
- package/public/icons/dusk/reminders.svg +1 -0
- package/public/icons/dusk/safari.svg +1 -0
- package/public/icons/dusk/sequel_pro.svg +1 -0
- package/public/icons/dusk/sketch.svg +1 -0
- package/public/icons/dusk/skype.svg +1 -0
- package/public/icons/dusk/slack.svg +1 -0
- package/public/icons/dusk/slack2.svg +1 -0
- package/public/icons/dusk/spotify.svg +1 -0
- package/public/icons/dusk/steam.svg +1 -0
- package/public/icons/dusk/system_preferences.svg +1 -0
- package/public/icons/dusk/tableplus.svg +1 -0
- package/public/icons/dusk/teams.svg +1 -0
- package/public/icons/dusk/telegram.svg +1 -0
- package/public/icons/dusk/terminal.svg +1 -0
- package/public/icons/dusk/todoist.svg +1 -0
- package/public/icons/dusk/trash.svg +1 -0
- package/public/icons/dusk/trello.svg +1 -0
- package/public/icons/dusk/vivaldi.svg +1 -0
- package/public/icons/dusk/vlc.svg +1 -0
- package/public/icons/dusk/vscode.svg +1 -0
- package/public/icons/dusk/whatsapp.svg +1 -0
- package/public/icons/dusk/xeyes.svg +1 -0
- package/public/icons/dusk/zoom.svg +1 -0
- package/public/icons/files.svg +5 -0
- package/public/icons/pwa/icon-192.png +0 -0
- package/public/icons/pwa/icon-512.png +0 -0
- package/public/icons/settings.svg +14 -0
- package/public/icons/terminal.svg +5 -0
- package/public/icons/text-editor.svg +7 -0
- package/public/manifest.json +38 -0
- package/public/next.svg +1 -0
- package/public/sw.js +41 -0
- package/public/vercel.svg +1 -0
- package/public/wallpapers/default.svg +10 -0
- package/public/window.svg +1 -0
- package/scripts/generate-pwa-icons.mjs +33 -0
- package/scripts/install-nats.sh +37 -0
- package/sentry.client.config.ts +21 -0
- package/sentry.edge.config.ts +12 -0
- package/sentry.server.config.ts +12 -0
- package/src/app/api/files/download/route.ts +81 -0
- package/src/app/api/files/download-zip/route.ts +102 -0
- package/src/app/api/files/upload/route.ts +58 -0
- package/src/app/api/webhooks/workos/route.ts +98 -0
- package/src/app/auth/callback/route.ts +16 -0
- package/src/app/auth/logout/route.ts +15 -0
- package/src/app/desktop/desktop-shell.tsx +110 -0
- package/src/app/desktop/layout.tsx +8 -0
- package/src/app/desktop/page.tsx +24 -0
- package/src/app/favicon.ico +0 -0
- package/src/app/globals.css +7 -0
- package/src/app/layout.tsx +64 -0
- package/src/app/offline/page.tsx +83 -0
- package/src/app/page.tsx +5 -0
- package/src/app/standalone/[appId]/page.tsx +28 -0
- package/src/app/standalone/layout.tsx +10 -0
- package/src/components/app-icon.tsx +55 -0
- package/src/components/apps/_echo/schema.ts +14 -0
- package/src/components/apps/_echo/service/index.ts +42 -0
- package/src/components/apps/app-manifest.ts +97 -0
- package/src/components/apps/app-registry.ts +55 -0
- package/src/components/apps/dev3000/Dev3000App.tsx +224 -0
- package/src/components/apps/dev3000/ErrorsPanel.tsx +160 -0
- package/src/components/apps/dev3000/Sidebar.tsx +41 -0
- package/src/components/apps/dev3000/TimelineLog.tsx +173 -0
- package/src/components/apps/dev3000/dev3000-context.tsx +29 -0
- package/src/components/apps/dev3000/index.ts +4 -0
- package/src/components/apps/dev3000/schema.ts +48 -0
- package/src/components/apps/dev3000/service/index.ts +520 -0
- package/src/components/apps/dev3000/service/runtime +1 -0
- package/src/components/apps/dev3000/types.ts +15 -0
- package/src/components/apps/dev3000/use-message-buffer.ts +46 -0
- package/src/components/apps/files/ContextMenu.tsx +151 -0
- package/src/components/apps/files/DeleteConfirmDialog.tsx +78 -0
- package/src/components/apps/files/FileItem.tsx +128 -0
- package/src/components/apps/files/FilesApp.tsx +509 -0
- package/src/components/apps/files/FilesListView.tsx +201 -0
- package/src/components/apps/files/FilesToolbar.tsx +117 -0
- package/src/components/apps/files/GridView.tsx +90 -0
- package/src/components/apps/files/InlineInput.tsx +131 -0
- package/src/components/apps/files/UploadOverlay.tsx +61 -0
- package/src/components/apps/files/schema.ts +49 -0
- package/src/components/apps/files/service/index.ts +227 -0
- package/src/components/apps/files/service/runtime +1 -0
- package/src/components/apps/files/use-files.ts +201 -0
- package/src/components/apps/files/use-upload.ts +105 -0
- package/src/components/apps/nats-viewer/ActiveSubs.tsx +34 -0
- package/src/components/apps/nats-viewer/MessageLog.tsx +247 -0
- package/src/components/apps/nats-viewer/NatsViewer.tsx +209 -0
- package/src/components/apps/nats-viewer/PublishPanel.tsx +113 -0
- package/src/components/apps/nats-viewer/RequestPanel.tsx +167 -0
- package/src/components/apps/nats-viewer/Sidebar.tsx +62 -0
- package/src/components/apps/nats-viewer/SubjectCatalog.tsx +64 -0
- package/src/components/apps/nats-viewer/SubscribeInput.tsx +59 -0
- package/src/components/apps/nats-viewer/index.ts +5 -0
- package/src/components/apps/nats-viewer/nats-viewer-context.tsx +31 -0
- package/src/components/apps/nats-viewer/types.ts +7 -0
- package/src/components/apps/nats-viewer/use-message-buffer.ts +55 -0
- package/src/components/apps/settings/Settings.tsx +492 -0
- package/src/components/apps/terminal/schema.ts +82 -0
- package/src/components/apps/terminal/service/index.ts +189 -0
- package/src/components/apps/terminal/service/runtime +1 -0
- package/src/components/apps/terminal/service/session.ts +296 -0
- package/src/components/apps/terminal/service/shell-hooks/bashrc-hook.sh +21 -0
- package/src/components/apps/terminal/types.ts +26 -0
- package/src/components/apps/terminal/ui/MultiTerminalApp.tsx +617 -0
- package/src/components/apps/terminal/ui/SplitDragHandle.tsx +91 -0
- package/src/components/apps/terminal/ui/SplitPaneRenderer.tsx +112 -0
- package/src/components/apps/terminal/ui/TerminalPane.tsx +476 -0
- package/src/components/apps/terminal/ui/TerminalTabBar.tsx +131 -0
- package/src/components/desktop/AnimatedBackground.tsx +69 -0
- package/src/components/desktop/Desktop.tsx +79 -0
- package/src/components/desktop/DesktopBackground.tsx +16 -0
- package/src/components/desktop/DesktopIcon.tsx +49 -0
- package/src/components/desktop/ShortcutViewer.tsx +136 -0
- package/src/components/desktop/WindowRenderer.tsx +34 -0
- package/src/components/desktop/WindowSwitcher.tsx +42 -0
- package/src/components/notifications/NotificationCenter.tsx +153 -0
- package/src/components/notifications/NotificationToasts.tsx +66 -0
- package/src/components/notifications/OrphanSessionToast.tsx +293 -0
- package/src/components/os-primitives/collapsible-sidebar.tsx +226 -0
- package/src/components/os-primitives/dialog.tsx +76 -0
- package/src/components/os-primitives/empty-state.tsx +43 -0
- package/src/components/os-primitives/index.ts +21 -0
- package/src/components/os-primitives/list-view.tsx +155 -0
- package/src/components/os-primitives/property-panel.tsx +108 -0
- package/src/components/os-primitives/section-header.tsx +19 -0
- package/src/components/os-primitives/sidebar-nav.tsx +110 -0
- package/src/components/os-primitives/split-pane.tsx +146 -0
- package/src/components/os-primitives/status-badge.tsx +10 -0
- package/src/components/os-primitives/status-bar.tsx +100 -0
- package/src/components/os-primitives/toolbar.tsx +152 -0
- package/src/components/taskbar/AppLauncher.tsx +114 -0
- package/src/components/taskbar/RunningApps.tsx +71 -0
- package/src/components/taskbar/SystemTray.tsx +134 -0
- package/src/components/taskbar/Taskbar.tsx +45 -0
- package/src/components/taskbar/UserMenu.tsx +138 -0
- package/src/components/taskbar/WorkspaceSwitcher.tsx +9 -0
- package/src/components/ui/ContextMenu.tsx +130 -0
- package/src/components/ui/badge.tsx +39 -0
- package/src/components/ui/button.tsx +102 -0
- package/src/components/ui/command.tsx +165 -0
- package/src/components/ui/dropdown-menu.tsx +233 -0
- package/src/components/ui/input.tsx +48 -0
- package/src/components/ui/note.tsx +55 -0
- package/src/components/ui/separator.tsx +25 -0
- package/src/components/ui/spinner.tsx +42 -0
- package/src/components/ui/switch.tsx +36 -0
- package/src/components/ui/theme-provider.tsx +24 -0
- package/src/components/ui/theme-switcher.tsx +51 -0
- package/src/components/ui/tooltip.tsx +62 -0
- package/src/components/window/MobileWindowStack.tsx +218 -0
- package/src/components/window/SnapPreview.tsx +37 -0
- package/src/components/window/StandaloneFrame.tsx +170 -0
- package/src/components/window/Window.tsx +423 -0
- package/src/components/window/WindowContent.tsx +14 -0
- package/src/components/window/WindowControlsOverlay.tsx +89 -0
- package/src/components/window/WindowFrame.tsx +124 -0
- package/src/lib/auth/index.ts +27 -0
- package/src/lib/auth/roles.ts +50 -0
- package/src/lib/auth/types.ts +32 -0
- package/src/lib/auth/use-auth.ts +53 -0
- package/src/lib/auth/webhook-handler.ts +87 -0
- package/src/lib/auth/workos.ts +67 -0
- package/src/lib/constants.ts +1 -0
- package/src/lib/desktop/dedup.ts +57 -0
- package/src/lib/desktop/schema.ts +55 -0
- package/src/lib/files/filename-validation.ts +41 -0
- package/src/lib/files/safe-path.ts +49 -0
- package/src/lib/hooks/use-desktop-nats.ts +438 -0
- package/src/lib/hooks/use-is-mobile.ts +23 -0
- package/src/lib/hooks/use-launch-app.ts +79 -0
- package/src/lib/hooks/use-nats-notifications.ts +84 -0
- package/src/lib/hooks/use-nats.ts +60 -0
- package/src/lib/hooks/use-visual-viewport.ts +72 -0
- package/src/lib/icons/resolve-window-icon.ts +10 -0
- package/src/lib/keyboard/defaults.ts +146 -0
- package/src/lib/keyboard/types.ts +52 -0
- package/src/lib/keyboard/use-global-keybinds.ts +231 -0
- package/src/lib/nats-client.ts +255 -0
- package/src/lib/nats.ts +35 -0
- package/src/lib/notifications/schema.ts +12 -0
- package/src/lib/service-loader.ts +171 -0
- package/src/lib/subjects.ts +64 -0
- package/src/lib/utils.ts +6 -0
- package/src/lib/ws-bridge.ts +288 -0
- package/src/lib/ws-protocol.ts +53 -0
- package/src/lib/ws-server.ts +167 -0
- package/src/middleware.ts +57 -0
- package/src/stores/desktop-store.ts +112 -0
- package/src/stores/keybind-store.ts +66 -0
- package/src/stores/notification-store.ts +271 -0
- package/src/stores/theme-store.ts +25 -0
- package/src/stores/window-store.ts +294 -0
- package/src/theme/animations.css +68 -0
- package/src/theme/base.css +123 -0
- package/src/theme/controls.css +35 -0
- package/src/theme/design-tokens.css +276 -0
- package/src/theme/index.css +23 -0
- package/src/theme/menus.css +45 -0
- package/src/theme/status.css +41 -0
- package/src/theme/surfaces.css +94 -0
- package/src/theme/tailwind-map.css +138 -0
- package/src/theme/taskbar.css +25 -0
- package/src/theme/terminal.css +55 -0
- package/src/theme/typography.css +26 -0
- package/src/theme/utilities.css +156 -0
- package/src/theme/window.css +103 -0
- package/src/types/desktop-entry.ts +12 -0
- package/src/types/use-descendants.d.ts +13 -0
- package/src/types/window.ts +28 -0
- package/src/types.d.ts +9 -0
- package/tauri/Cargo.lock +5464 -0
- package/tauri/Cargo.toml +19 -0
- package/tauri/build.rs +3 -0
- package/tauri/capabilities/default.json +36 -0
- package/tauri/icons/128x128.png +0 -0
- package/tauri/icons/128x128@2x.png +0 -0
- package/tauri/icons/32x32.png +0 -0
- package/tauri/icons/icon.png +0 -0
- package/tauri/src/main.rs +396 -0
- package/tauri/tauri.conf.json +23 -0
- package/tsconfig.json +43 -0
|
@@ -0,0 +1,615 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useCallback, useEffect, useRef, useState } from 'react';
|
|
4
|
+
import { v4 as uuid } from 'uuid';
|
|
5
|
+
import { getNatsClient } from '@/lib/nats-client';
|
|
6
|
+
import { SUBJECTS } from '@/lib/subjects';
|
|
7
|
+
import { useWindowStore } from '@/stores/window-store';
|
|
8
|
+
import type { SplitNode, TerminalTab } from '../types';
|
|
9
|
+
import { SplitPaneRenderer } from './SplitPaneRenderer';
|
|
10
|
+
import { TerminalTabBar } from './TerminalTabBar';
|
|
11
|
+
|
|
12
|
+
/** Check if a split tree contains a pane with the given ID */
|
|
13
|
+
function treeContainsPane(node: SplitNode, paneId: string): boolean {
|
|
14
|
+
if (node.type === 'leaf') return node.id === paneId;
|
|
15
|
+
return treeContainsPane(node.children[0], paneId) || treeContainsPane(node.children[1], paneId);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** Update ratio for a specific branch node, preserving references when unchanged */
|
|
19
|
+
function updateBranchRatio(node: SplitNode, nodeId: string, ratio: number): SplitNode {
|
|
20
|
+
if (node.type === 'leaf') return node;
|
|
21
|
+
if (node.id === nodeId) return { ...node, ratio };
|
|
22
|
+
const c0 = updateBranchRatio(node.children[0], nodeId, ratio);
|
|
23
|
+
const c1 = updateBranchRatio(node.children[1], nodeId, ratio);
|
|
24
|
+
if (c0 === node.children[0] && c1 === node.children[1]) return node;
|
|
25
|
+
return { ...node, children: [c0, c1] as [SplitNode, SplitNode] };
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Recursively collect all pane IDs from a split tree */
|
|
29
|
+
function collectPaneIds(node: SplitNode): string[] {
|
|
30
|
+
if (node.type === 'leaf') return [node.id];
|
|
31
|
+
return [...collectPaneIds(node.children[0]), ...collectPaneIds(node.children[1])];
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Recursively find and remove a pane from the tree, promoting siblings */
|
|
35
|
+
function removePaneFromTree(node: SplitNode, paneId: string): SplitNode | null {
|
|
36
|
+
if (node.type === 'leaf') {
|
|
37
|
+
return node.id === paneId ? null : node;
|
|
38
|
+
}
|
|
39
|
+
const newChild0 = removePaneFromTree(node.children[0], paneId);
|
|
40
|
+
const newChild1 = removePaneFromTree(node.children[1], paneId);
|
|
41
|
+
if (newChild0 === null) return newChild1;
|
|
42
|
+
if (newChild1 === null) return newChild0;
|
|
43
|
+
return { ...node, children: [newChild0, newChild1] };
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Recursively collect all PTY session IDs from a split tree */
|
|
47
|
+
function collectPtySessionIds(node: SplitNode): string[] {
|
|
48
|
+
if (node.type === 'leaf') return node.ptySessionId ? [node.ptySessionId] : [];
|
|
49
|
+
return [...collectPtySessionIds(node.children[0]), ...collectPtySessionIds(node.children[1])];
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Module-level set — survives React Strict Mode double-mount (useRef resets between mounts)
|
|
53
|
+
const initedWindows = new Set<string>();
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Multi-tab terminal app with split panes.
|
|
57
|
+
* Each tab has a recursive split tree of panes.
|
|
58
|
+
* Each leaf pane has its own PTY session and xterm instance.
|
|
59
|
+
*
|
|
60
|
+
* Keyboard shortcuts (via attachCustomKeyEventHandler):
|
|
61
|
+
* - Cmd+T: new tab
|
|
62
|
+
* - Cmd+W: close active pane (or tab if last pane, or window if last tab)
|
|
63
|
+
* - Cmd+D: split active pane vertically
|
|
64
|
+
* - Cmd+Shift+D: split active pane horizontally
|
|
65
|
+
* - Ctrl+Tab: next tab
|
|
66
|
+
* - Ctrl+Shift+Tab: prev tab
|
|
67
|
+
*/
|
|
68
|
+
export function MultiTerminalApp(props: { windowId: string; meta?: Record<string, unknown> }) {
|
|
69
|
+
const [tabs, setTabs] = useState<TerminalTab[]>([]);
|
|
70
|
+
const [activeTabId, setActiveTabId] = useState<string>('');
|
|
71
|
+
|
|
72
|
+
// Track whether init has run (prevents meta effect from clearing persisted state on mount)
|
|
73
|
+
const initializedRef = useRef(false);
|
|
74
|
+
|
|
75
|
+
// One-shot guard: publish event.metaUpdated once when PTY sessions first appear
|
|
76
|
+
const metaPublishedRef = useRef(false);
|
|
77
|
+
|
|
78
|
+
// Keep a ref to latest tabs for use in callbacks without stale closures
|
|
79
|
+
const tabsRef = useRef(tabs);
|
|
80
|
+
tabsRef.current = tabs;
|
|
81
|
+
|
|
82
|
+
const updateWindowMeta = useWindowStore((s) => s.updateWindowMeta);
|
|
83
|
+
const closeWindow = useWindowStore((s) => s.closeWindow);
|
|
84
|
+
|
|
85
|
+
// Helper: create a new leaf node
|
|
86
|
+
const createLeafNode = useCallback((): SplitNode => {
|
|
87
|
+
return {
|
|
88
|
+
type: 'leaf',
|
|
89
|
+
id: uuid(),
|
|
90
|
+
ptySessionId: null,
|
|
91
|
+
cwd: null,
|
|
92
|
+
lastCommand: null,
|
|
93
|
+
};
|
|
94
|
+
}, []);
|
|
95
|
+
|
|
96
|
+
// Create a new tab
|
|
97
|
+
const createTab = useCallback(() => {
|
|
98
|
+
const leafNode = createLeafNode();
|
|
99
|
+
const newTab: TerminalTab = {
|
|
100
|
+
id: uuid(),
|
|
101
|
+
ptySessionId: null, // legacy, kept for compatibility
|
|
102
|
+
title: 'bash',
|
|
103
|
+
cwd: null,
|
|
104
|
+
lastCommand: null,
|
|
105
|
+
splitTree: leafNode,
|
|
106
|
+
focusedPaneId: leafNode.id,
|
|
107
|
+
};
|
|
108
|
+
setTabs((prev) => [...prev, newTab]);
|
|
109
|
+
setActiveTabId(newTab.id);
|
|
110
|
+
return newTab.id;
|
|
111
|
+
}, [createLeafNode]);
|
|
112
|
+
|
|
113
|
+
// Destroy PTY sessions directly via the NatsClient singleton (bypasses React hooks)
|
|
114
|
+
const destroyPtySessions = useCallback((sessionIds: string[]) => {
|
|
115
|
+
const client = getNatsClient();
|
|
116
|
+
for (const sessionId of sessionIds) {
|
|
117
|
+
client.publish(SUBJECTS.pty.destroy(client.orgId), { sessionId });
|
|
118
|
+
}
|
|
119
|
+
}, []);
|
|
120
|
+
|
|
121
|
+
// Close the focused pane in the active tab (Cmd+W)
|
|
122
|
+
const closePane = useCallback(() => {
|
|
123
|
+
// Read snapshot for side effects (PTY destroy) only
|
|
124
|
+
const activeTab = tabsRef.current.find((t) => t.id === activeTabId);
|
|
125
|
+
if (!activeTab) return;
|
|
126
|
+
|
|
127
|
+
const allPaneIds = collectPaneIds(activeTab.splitTree);
|
|
128
|
+
|
|
129
|
+
// If only one pane, close the tab
|
|
130
|
+
if (allPaneIds.length === 1) {
|
|
131
|
+
const sessionIds = collectPtySessionIds(activeTab.splitTree);
|
|
132
|
+
destroyPtySessions(sessionIds);
|
|
133
|
+
|
|
134
|
+
setTabs((prev) => {
|
|
135
|
+
const newTabs = prev.filter((t) => t.id !== activeTabId);
|
|
136
|
+
|
|
137
|
+
if (newTabs.length === 0) {
|
|
138
|
+
closeWindow(props.windowId);
|
|
139
|
+
return newTabs;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const idx = prev.findIndex((t) => t.id === activeTabId);
|
|
143
|
+
const nextIdx = idx > 0 ? idx - 1 : 0;
|
|
144
|
+
setActiveTabId(newTabs[nextIdx].id);
|
|
145
|
+
|
|
146
|
+
return newTabs;
|
|
147
|
+
});
|
|
148
|
+
return;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// Multiple panes — destroy the focused pane's PTY session (side effect from snapshot)
|
|
152
|
+
const focusedLeaf = (function findLeaf(node: SplitNode): SplitNode | null {
|
|
153
|
+
if (node.type === 'leaf') return node.id === activeTab.focusedPaneId ? node : null;
|
|
154
|
+
return findLeaf(node.children[0]) || findLeaf(node.children[1]);
|
|
155
|
+
})(activeTab.splitTree);
|
|
156
|
+
if (focusedLeaf && focusedLeaf.type === 'leaf' && focusedLeaf.ptySessionId) {
|
|
157
|
+
destroyPtySessions([focusedLeaf.ptySessionId]);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// Compute new tree from prev inside updater to avoid lost updates
|
|
161
|
+
setTabs((prev) =>
|
|
162
|
+
prev.map((t) => {
|
|
163
|
+
if (t.id !== activeTabId) return t;
|
|
164
|
+
const newTree = removePaneFromTree(t.splitTree, t.focusedPaneId);
|
|
165
|
+
if (!newTree) return t;
|
|
166
|
+
const newPaneIds = collectPaneIds(newTree);
|
|
167
|
+
const newFocusedPaneId = newPaneIds[0] || newTree.id;
|
|
168
|
+
return { ...t, splitTree: newTree, focusedPaneId: newFocusedPaneId };
|
|
169
|
+
})
|
|
170
|
+
);
|
|
171
|
+
}, [activeTabId, destroyPtySessions, closeWindow, props.windowId]);
|
|
172
|
+
|
|
173
|
+
// Close a specific tab by ID (tab X button)
|
|
174
|
+
const closeTab = useCallback(
|
|
175
|
+
(tabId: string) => {
|
|
176
|
+
const tab = tabsRef.current.find((t) => t.id === tabId);
|
|
177
|
+
if (!tab) return;
|
|
178
|
+
|
|
179
|
+
const sessionIds = collectPtySessionIds(tab.splitTree);
|
|
180
|
+
destroyPtySessions(sessionIds);
|
|
181
|
+
|
|
182
|
+
setTabs((prev) => {
|
|
183
|
+
const newTabs = prev.filter((t) => t.id !== tabId);
|
|
184
|
+
|
|
185
|
+
if (newTabs.length === 0) {
|
|
186
|
+
closeWindow(props.windowId);
|
|
187
|
+
return newTabs;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
if (tabId === activeTabId) {
|
|
191
|
+
const idx = prev.findIndex((t) => t.id === tabId);
|
|
192
|
+
const nextIdx = idx > 0 ? idx - 1 : 0;
|
|
193
|
+
setActiveTabId(newTabs[nextIdx].id);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
return newTabs;
|
|
197
|
+
});
|
|
198
|
+
},
|
|
199
|
+
[activeTabId, destroyPtySessions, closeWindow, props.windowId]
|
|
200
|
+
);
|
|
201
|
+
|
|
202
|
+
// Helper: split a pane in the tree
|
|
203
|
+
const splitPaneInTree = useCallback(
|
|
204
|
+
(node: SplitNode, paneId: string, direction: 'horizontal' | 'vertical'): SplitNode => {
|
|
205
|
+
if (node.type === 'leaf') {
|
|
206
|
+
if (node.id === paneId) {
|
|
207
|
+
const newLeaf = createLeafNode();
|
|
208
|
+
return {
|
|
209
|
+
type: 'branch',
|
|
210
|
+
id: uuid(),
|
|
211
|
+
direction,
|
|
212
|
+
children: [node, newLeaf],
|
|
213
|
+
ratio: 0.5,
|
|
214
|
+
};
|
|
215
|
+
}
|
|
216
|
+
return node;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
return {
|
|
220
|
+
...node,
|
|
221
|
+
children: [
|
|
222
|
+
splitPaneInTree(node.children[0], paneId, direction),
|
|
223
|
+
splitPaneInTree(node.children[1], paneId, direction),
|
|
224
|
+
] as [SplitNode, SplitNode],
|
|
225
|
+
};
|
|
226
|
+
},
|
|
227
|
+
[createLeafNode]
|
|
228
|
+
);
|
|
229
|
+
|
|
230
|
+
// Split the focused pane — tree computed inside updater to avoid lost updates
|
|
231
|
+
const splitPane = useCallback(
|
|
232
|
+
(direction: 'horizontal' | 'vertical') => {
|
|
233
|
+
setTabs((prev) => {
|
|
234
|
+
const activeTab = prev.find((t) => t.id === activeTabId);
|
|
235
|
+
if (!activeTab) return prev;
|
|
236
|
+
const newTree = splitPaneInTree(activeTab.splitTree, activeTab.focusedPaneId, direction);
|
|
237
|
+
return prev.map((t) => (t.id === activeTabId ? { ...t, splitTree: newTree } : t));
|
|
238
|
+
});
|
|
239
|
+
},
|
|
240
|
+
[activeTabId, splitPaneInTree]
|
|
241
|
+
);
|
|
242
|
+
|
|
243
|
+
// Switch to next tab (Ctrl+Tab)
|
|
244
|
+
const nextTab = useCallback(() => {
|
|
245
|
+
const current = tabsRef.current;
|
|
246
|
+
if (current.length === 0) return;
|
|
247
|
+
const idx = current.findIndex((t) => t.id === activeTabId);
|
|
248
|
+
const nextIdx = (idx + 1) % current.length;
|
|
249
|
+
setActiveTabId(current[nextIdx].id);
|
|
250
|
+
}, [activeTabId]);
|
|
251
|
+
|
|
252
|
+
// Switch to previous tab (Ctrl+Shift+Tab)
|
|
253
|
+
const prevTab = useCallback(() => {
|
|
254
|
+
const current = tabsRef.current;
|
|
255
|
+
if (current.length === 0) return;
|
|
256
|
+
const idx = current.findIndex((t) => t.id === activeTabId);
|
|
257
|
+
const nextIdx = idx === 0 ? current.length - 1 : idx - 1;
|
|
258
|
+
setActiveTabId(current[nextIdx].id);
|
|
259
|
+
}, [activeTabId]);
|
|
260
|
+
|
|
261
|
+
// Initialize: restore from meta or create new tab.
|
|
262
|
+
// Uses module-level `initedWindows` set instead of useRef to survive React
|
|
263
|
+
// Strict Mode double-mount (refs reset between mounts, module state doesn't).
|
|
264
|
+
// Reads meta from the Zustand store directly (not props.meta) because
|
|
265
|
+
// updateWindowMeta writes via queueMicrotask which may not have flushed.
|
|
266
|
+
useEffect(() => {
|
|
267
|
+
if (tabs.length > 0 || initedWindows.has(props.windowId)) return;
|
|
268
|
+
|
|
269
|
+
const win = useWindowStore
|
|
270
|
+
.getState()
|
|
271
|
+
.getWindows()
|
|
272
|
+
.find((w) => w.id === props.windowId);
|
|
273
|
+
const meta = win?.meta;
|
|
274
|
+
|
|
275
|
+
// Synced window — wait for meta with PTY sessions to arrive
|
|
276
|
+
if (meta?._awaitingMeta) return;
|
|
277
|
+
|
|
278
|
+
initedWindows.add(props.windowId);
|
|
279
|
+
|
|
280
|
+
if (meta?.tabs && Array.isArray(meta.tabs) && meta.tabs.length > 0) {
|
|
281
|
+
const seen = new Set<string>();
|
|
282
|
+
const uniqueTabs = (meta.tabs as TerminalTab[]).filter((tab) => {
|
|
283
|
+
if (seen.has(tab.id)) return false;
|
|
284
|
+
seen.add(tab.id);
|
|
285
|
+
return true;
|
|
286
|
+
});
|
|
287
|
+
setTabs(uniqueTabs);
|
|
288
|
+
setActiveTabId((meta.activeTabId as string) || uniqueTabs[0].id);
|
|
289
|
+
} else {
|
|
290
|
+
createTab();
|
|
291
|
+
}
|
|
292
|
+
initializedRef.current = true;
|
|
293
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
294
|
+
}, []); // Run once on mount
|
|
295
|
+
|
|
296
|
+
// Clear initedWindows when the window is truly closed (not during Strict Mode remount).
|
|
297
|
+
// On Strict Mode remount the window still exists in the store, so we keep the guard.
|
|
298
|
+
useEffect(() => {
|
|
299
|
+
const wid = props.windowId;
|
|
300
|
+
return () => {
|
|
301
|
+
const exists = useWindowStore
|
|
302
|
+
.getState()
|
|
303
|
+
.getWindows()
|
|
304
|
+
.some((w) => w.id === wid);
|
|
305
|
+
if (!exists) initedWindows.delete(wid);
|
|
306
|
+
};
|
|
307
|
+
}, [props.windowId]);
|
|
308
|
+
|
|
309
|
+
// Watch for remote meta arrival (cross-tab PTY sharing)
|
|
310
|
+
useEffect(() => {
|
|
311
|
+
if (initializedRef.current || tabs.length > 0) return;
|
|
312
|
+
|
|
313
|
+
const unsub = useWindowStore.subscribe((state) => {
|
|
314
|
+
if (initializedRef.current) return;
|
|
315
|
+
const win = state.getWindows().find((w) => w.id === props.windowId);
|
|
316
|
+
if (!win?.meta?.tabs || !Array.isArray(win.meta.tabs)) return;
|
|
317
|
+
if (win.meta._awaitingMeta) return;
|
|
318
|
+
|
|
319
|
+
// Ensure at least one tab has a real PTY session (not null)
|
|
320
|
+
const metaTabs = win.meta.tabs as TerminalTab[];
|
|
321
|
+
const hasSession = metaTabs.some((t) => collectPtySessionIds(t.splitTree).length > 0);
|
|
322
|
+
if (!hasSession) return;
|
|
323
|
+
|
|
324
|
+
// Meta arrived with PTY sessions — initialize from shared sessions
|
|
325
|
+
initedWindows.add(props.windowId);
|
|
326
|
+
const seen = new Set<string>();
|
|
327
|
+
const uniqueTabs = metaTabs.filter((tab) => {
|
|
328
|
+
if (seen.has(tab.id)) return false;
|
|
329
|
+
seen.add(tab.id);
|
|
330
|
+
return true;
|
|
331
|
+
});
|
|
332
|
+
setTabs(uniqueTabs);
|
|
333
|
+
setActiveTabId((win.meta.activeTabId as string) || uniqueTabs[0].id);
|
|
334
|
+
initializedRef.current = true;
|
|
335
|
+
});
|
|
336
|
+
|
|
337
|
+
return unsub;
|
|
338
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
339
|
+
}, [props.windowId]);
|
|
340
|
+
|
|
341
|
+
// Timeout fallback: create fresh tabs if synced meta doesn't arrive in 3s
|
|
342
|
+
useEffect(() => {
|
|
343
|
+
if (initializedRef.current || tabs.length > 0) return;
|
|
344
|
+
|
|
345
|
+
const win = useWindowStore
|
|
346
|
+
.getState()
|
|
347
|
+
.getWindows()
|
|
348
|
+
.find((w) => w.id === props.windowId);
|
|
349
|
+
if (!win?.meta?._awaitingMeta) return;
|
|
350
|
+
|
|
351
|
+
const timeout = setTimeout(() => {
|
|
352
|
+
if (initializedRef.current || tabsRef.current.length > 0) return;
|
|
353
|
+
initedWindows.add(props.windowId);
|
|
354
|
+
createTab();
|
|
355
|
+
initializedRef.current = true;
|
|
356
|
+
}, 3000);
|
|
357
|
+
|
|
358
|
+
return () => clearTimeout(timeout);
|
|
359
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
360
|
+
}, [props.windowId]);
|
|
361
|
+
|
|
362
|
+
// Handle pane focus
|
|
363
|
+
const handlePaneFocus = useCallback((paneId: string) => {
|
|
364
|
+
setTabs((prev) =>
|
|
365
|
+
prev.map((t) => {
|
|
366
|
+
if (!treeContainsPane(t.splitTree, paneId)) return t;
|
|
367
|
+
|
|
368
|
+
const findPaneData = (node: SplitNode): { cwd: string | null; lastCommand: string | null } | null => {
|
|
369
|
+
if (node.type === 'leaf') {
|
|
370
|
+
return node.id === paneId ? { cwd: node.cwd, lastCommand: node.lastCommand } : null;
|
|
371
|
+
}
|
|
372
|
+
return findPaneData(node.children[0]) || findPaneData(node.children[1]);
|
|
373
|
+
};
|
|
374
|
+
|
|
375
|
+
const paneData = findPaneData(t.splitTree);
|
|
376
|
+
|
|
377
|
+
return {
|
|
378
|
+
...t,
|
|
379
|
+
focusedPaneId: paneId,
|
|
380
|
+
cwd: paneData?.cwd ?? t.cwd,
|
|
381
|
+
lastCommand: paneData?.lastCommand ?? t.lastCommand,
|
|
382
|
+
};
|
|
383
|
+
})
|
|
384
|
+
);
|
|
385
|
+
}, []);
|
|
386
|
+
|
|
387
|
+
// Handle session ID change for a pane
|
|
388
|
+
const handleSessionIdChange = useCallback((paneId: string, sessionId: string) => {
|
|
389
|
+
setTabs((prev) =>
|
|
390
|
+
prev.map((t) => {
|
|
391
|
+
if (!treeContainsPane(t.splitTree, paneId)) return t;
|
|
392
|
+
|
|
393
|
+
const updateSessionId = (node: SplitNode): SplitNode => {
|
|
394
|
+
if (node.type === 'leaf') {
|
|
395
|
+
return node.id === paneId ? { ...node, ptySessionId: sessionId } : node;
|
|
396
|
+
}
|
|
397
|
+
return {
|
|
398
|
+
...node,
|
|
399
|
+
children: [updateSessionId(node.children[0]), updateSessionId(node.children[1])] as [SplitNode, SplitNode],
|
|
400
|
+
};
|
|
401
|
+
};
|
|
402
|
+
|
|
403
|
+
return {
|
|
404
|
+
...t,
|
|
405
|
+
splitTree: updateSessionId(t.splitTree),
|
|
406
|
+
};
|
|
407
|
+
})
|
|
408
|
+
);
|
|
409
|
+
}, []);
|
|
410
|
+
|
|
411
|
+
// Handle CWD change for a pane (OSC 7)
|
|
412
|
+
const handleCwdChange = useCallback((paneId: string, cwd: string) => {
|
|
413
|
+
setTabs((prev) =>
|
|
414
|
+
prev.map((t) => {
|
|
415
|
+
if (!treeContainsPane(t.splitTree, paneId)) return t;
|
|
416
|
+
|
|
417
|
+
const updateCwd = (node: SplitNode): SplitNode => {
|
|
418
|
+
if (node.type === 'leaf') {
|
|
419
|
+
return node.id === paneId ? { ...node, cwd } : node;
|
|
420
|
+
}
|
|
421
|
+
return {
|
|
422
|
+
...node,
|
|
423
|
+
children: [updateCwd(node.children[0]), updateCwd(node.children[1])] as [SplitNode, SplitNode],
|
|
424
|
+
};
|
|
425
|
+
};
|
|
426
|
+
|
|
427
|
+
const newTree = updateCwd(t.splitTree);
|
|
428
|
+
const newCwd = t.focusedPaneId === paneId ? cwd : t.cwd;
|
|
429
|
+
|
|
430
|
+
return {
|
|
431
|
+
...t,
|
|
432
|
+
splitTree: newTree,
|
|
433
|
+
cwd: newCwd,
|
|
434
|
+
};
|
|
435
|
+
})
|
|
436
|
+
);
|
|
437
|
+
}, []);
|
|
438
|
+
|
|
439
|
+
// Handle last command change for a pane
|
|
440
|
+
const handleLastCommandChange = useCallback((paneId: string, command: string) => {
|
|
441
|
+
setTabs((prev) =>
|
|
442
|
+
prev.map((t) => {
|
|
443
|
+
if (!treeContainsPane(t.splitTree, paneId)) return t;
|
|
444
|
+
|
|
445
|
+
const updateLastCommand = (node: SplitNode): SplitNode => {
|
|
446
|
+
if (node.type === 'leaf') {
|
|
447
|
+
return node.id === paneId ? { ...node, lastCommand: command } : node;
|
|
448
|
+
}
|
|
449
|
+
return {
|
|
450
|
+
...node,
|
|
451
|
+
children: [updateLastCommand(node.children[0]), updateLastCommand(node.children[1])] as [
|
|
452
|
+
SplitNode,
|
|
453
|
+
SplitNode,
|
|
454
|
+
],
|
|
455
|
+
};
|
|
456
|
+
};
|
|
457
|
+
|
|
458
|
+
const newTree = updateLastCommand(t.splitTree);
|
|
459
|
+
const newLastCommand = t.focusedPaneId === paneId ? command : t.lastCommand;
|
|
460
|
+
|
|
461
|
+
return {
|
|
462
|
+
...t,
|
|
463
|
+
splitTree: newTree,
|
|
464
|
+
lastCommand: newLastCommand,
|
|
465
|
+
};
|
|
466
|
+
})
|
|
467
|
+
);
|
|
468
|
+
}, []);
|
|
469
|
+
|
|
470
|
+
// Handle ratio change for a branch node
|
|
471
|
+
const handleRatioChange = useCallback((nodeId: string, ratio: number) => {
|
|
472
|
+
setTabs((prev) =>
|
|
473
|
+
prev.map((t) => {
|
|
474
|
+
const newTree = updateBranchRatio(t.splitTree, nodeId, ratio);
|
|
475
|
+
return newTree === t.splitTree ? t : { ...t, splitTree: newTree };
|
|
476
|
+
})
|
|
477
|
+
);
|
|
478
|
+
}, []);
|
|
479
|
+
|
|
480
|
+
// Keyboard shortcut handler
|
|
481
|
+
const handleKeyboardShortcut = useCallback(
|
|
482
|
+
(event: KeyboardEvent): boolean => {
|
|
483
|
+
const isMac = /Mac|iPhone|iPad|iPod/.test(navigator.userAgent);
|
|
484
|
+
const cmdKey = isMac ? event.metaKey : event.ctrlKey;
|
|
485
|
+
|
|
486
|
+
// Cmd+T: new tab
|
|
487
|
+
if (cmdKey && event.key === 't' && !event.shiftKey && !event.altKey) {
|
|
488
|
+
event.preventDefault();
|
|
489
|
+
createTab();
|
|
490
|
+
return false;
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
// Cmd+W: close pane
|
|
494
|
+
if (cmdKey && event.key === 'w' && !event.shiftKey && !event.altKey) {
|
|
495
|
+
event.preventDefault();
|
|
496
|
+
closePane();
|
|
497
|
+
return false;
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
// Cmd+D: split vertical
|
|
501
|
+
if (cmdKey && event.key === 'd' && !event.shiftKey && !event.altKey) {
|
|
502
|
+
event.preventDefault();
|
|
503
|
+
splitPane('vertical');
|
|
504
|
+
return false;
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
// Cmd+Shift+D: split horizontal
|
|
508
|
+
if (cmdKey && event.key === 'D' && event.shiftKey && !event.altKey) {
|
|
509
|
+
event.preventDefault();
|
|
510
|
+
splitPane('horizontal');
|
|
511
|
+
return false;
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
// Ctrl+Tab: next tab (terminal-internal only)
|
|
515
|
+
if (event.ctrlKey && event.key === 'Tab' && !event.shiftKey && !event.metaKey) {
|
|
516
|
+
event.preventDefault();
|
|
517
|
+
nextTab();
|
|
518
|
+
return false;
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
// Ctrl+Shift+Tab: prev tab
|
|
522
|
+
if (event.ctrlKey && event.key === 'Tab' && event.shiftKey && !event.metaKey) {
|
|
523
|
+
event.preventDefault();
|
|
524
|
+
prevTab();
|
|
525
|
+
return false;
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
// Let xterm handle everything else
|
|
529
|
+
return true;
|
|
530
|
+
},
|
|
531
|
+
[createTab, closePane, splitPane, nextTab, prevTab]
|
|
532
|
+
);
|
|
533
|
+
|
|
534
|
+
// Update window meta when tabs change (deferred to avoid updating store during commit phase)
|
|
535
|
+
useEffect(() => {
|
|
536
|
+
if (!initializedRef.current || tabs.length === 0) return;
|
|
537
|
+
|
|
538
|
+
// One-shot: publish event.metaUpdated directly via NATS when PTY sessions
|
|
539
|
+
// first become available. This enables cross-tab PTY session sharing —
|
|
540
|
+
// other tabs waiting for sessions can reattach instead of creating new ones.
|
|
541
|
+
if (!metaPublishedRef.current) {
|
|
542
|
+
const sessionIds = tabs.flatMap((t) => collectPtySessionIds(t.splitTree));
|
|
543
|
+
if (sessionIds.length > 0) {
|
|
544
|
+
metaPublishedRef.current = true;
|
|
545
|
+
const client = getNatsClient();
|
|
546
|
+
if (client.userId) {
|
|
547
|
+
client.publish(SUBJECTS.desktop.event.metaUpdated(client.orgId, client.userId), {
|
|
548
|
+
windowId: props.windowId,
|
|
549
|
+
meta: { tabs, activeTabId },
|
|
550
|
+
});
|
|
551
|
+
}
|
|
552
|
+
}
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
queueMicrotask(() => {
|
|
556
|
+
updateWindowMeta(props.windowId, { tabs, activeTabId });
|
|
557
|
+
});
|
|
558
|
+
}, [tabs, activeTabId, props.windowId, updateWindowMeta]);
|
|
559
|
+
|
|
560
|
+
// Collect all session IDs from current tabs ref
|
|
561
|
+
const collectAllSessionIds = useCallback(() => {
|
|
562
|
+
return tabsRef.current.flatMap((tab) => collectPtySessionIds(tab.splitTree));
|
|
563
|
+
}, []);
|
|
564
|
+
|
|
565
|
+
// Watch for window closing flag (set by closeWindow from the X button).
|
|
566
|
+
// Uses a zustand store subscription so it fires synchronously — before the
|
|
567
|
+
// microtask that removes the window and unmounts the component.
|
|
568
|
+
useEffect(() => {
|
|
569
|
+
const unsub = useWindowStore.subscribe((state, prevState) => {
|
|
570
|
+
const wsId = state.activeWorkspaceId;
|
|
571
|
+
if (!wsId) return;
|
|
572
|
+
const win = (state.windowsByWorkspace[wsId] || []).find((w) => w.id === props.windowId);
|
|
573
|
+
const prevWin = (prevState.windowsByWorkspace[wsId] || []).find((w) => w.id === props.windowId);
|
|
574
|
+
if (win?.closing && !prevWin?.closing) {
|
|
575
|
+
destroyPtySessions(collectAllSessionIds());
|
|
576
|
+
}
|
|
577
|
+
});
|
|
578
|
+
return unsub;
|
|
579
|
+
}, [props.windowId, destroyPtySessions, collectAllSessionIds]);
|
|
580
|
+
|
|
581
|
+
return (
|
|
582
|
+
<div
|
|
583
|
+
className="h-full w-full flex flex-col"
|
|
584
|
+
style={{ background: 'var(--os-terminal-bg, var(--os-surface-sunken))' }}
|
|
585
|
+
>
|
|
586
|
+
<TerminalTabBar
|
|
587
|
+
tabs={tabs}
|
|
588
|
+
activeTabId={activeTabId}
|
|
589
|
+
onTabClick={setActiveTabId}
|
|
590
|
+
onTabClose={closeTab}
|
|
591
|
+
onNewTab={createTab}
|
|
592
|
+
/>
|
|
593
|
+
<div data-no-drag className="flex-1 overflow-hidden relative">
|
|
594
|
+
{tabs.map((tab) => (
|
|
595
|
+
<div
|
|
596
|
+
key={tab.id}
|
|
597
|
+
className="absolute inset-0"
|
|
598
|
+
style={{ visibility: tab.id === activeTabId ? 'visible' : 'hidden' }}
|
|
599
|
+
>
|
|
600
|
+
<SplitPaneRenderer
|
|
601
|
+
node={tab.splitTree}
|
|
602
|
+
focusedPaneId={tab.id === activeTabId ? tab.focusedPaneId : ''}
|
|
603
|
+
onPaneFocus={handlePaneFocus}
|
|
604
|
+
onSessionIdChange={handleSessionIdChange}
|
|
605
|
+
onKeyboardShortcut={handleKeyboardShortcut}
|
|
606
|
+
onRatioChange={handleRatioChange}
|
|
607
|
+
onCwdChange={handleCwdChange}
|
|
608
|
+
onLastCommandChange={handleLastCommandChange}
|
|
609
|
+
/>
|
|
610
|
+
</div>
|
|
611
|
+
))}
|
|
612
|
+
</div>
|
|
613
|
+
</div>
|
|
614
|
+
);
|
|
615
|
+
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useCallback, useRef } from 'react';
|
|
4
|
+
|
|
5
|
+
interface SplitDragHandleProps {
|
|
6
|
+
direction: 'horizontal' | 'vertical';
|
|
7
|
+
onRatioChange: (ratio: number) => void;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Draggable divider between split panes.
|
|
12
|
+
* Vertical split = horizontal drag handle (side by side panes).
|
|
13
|
+
* Horizontal split = vertical drag handle (top/bottom panes).
|
|
14
|
+
*/
|
|
15
|
+
export function SplitDragHandle({ direction, onRatioChange }: SplitDragHandleProps) {
|
|
16
|
+
const isDraggingRef = useRef(false);
|
|
17
|
+
const containerRef = useRef<HTMLDivElement | null>(null);
|
|
18
|
+
|
|
19
|
+
const handleMouseDown = useCallback(
|
|
20
|
+
(e: React.MouseEvent) => {
|
|
21
|
+
e.preventDefault();
|
|
22
|
+
isDraggingRef.current = true;
|
|
23
|
+
|
|
24
|
+
// Find the parent split container
|
|
25
|
+
let parent = e.currentTarget.parentElement;
|
|
26
|
+
while (parent && !parent.classList.contains('split-container')) {
|
|
27
|
+
parent = parent.parentElement;
|
|
28
|
+
}
|
|
29
|
+
containerRef.current = parent as HTMLDivElement;
|
|
30
|
+
|
|
31
|
+
const handleMouseMove = (moveEvent: MouseEvent) => {
|
|
32
|
+
if (!isDraggingRef.current || !containerRef.current) return;
|
|
33
|
+
|
|
34
|
+
const rect = containerRef.current.getBoundingClientRect();
|
|
35
|
+
let ratio: number;
|
|
36
|
+
|
|
37
|
+
if (direction === 'vertical') {
|
|
38
|
+
// Vertical split: side by side, calculate X position
|
|
39
|
+
const x = moveEvent.clientX - rect.left;
|
|
40
|
+
ratio = x / rect.width;
|
|
41
|
+
} else {
|
|
42
|
+
// Horizontal split: top/bottom, calculate Y position
|
|
43
|
+
const y = moveEvent.clientY - rect.top;
|
|
44
|
+
ratio = y / rect.height;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// Clamp ratio between 0.1 and 0.9 to prevent extremely small panes
|
|
48
|
+
ratio = Math.max(0.1, Math.min(0.9, ratio));
|
|
49
|
+
onRatioChange(ratio);
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
const handleMouseUp = () => {
|
|
53
|
+
isDraggingRef.current = false;
|
|
54
|
+
containerRef.current = null;
|
|
55
|
+
document.removeEventListener('mousemove', handleMouseMove);
|
|
56
|
+
document.removeEventListener('mouseup', handleMouseUp);
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
document.addEventListener('mousemove', handleMouseMove);
|
|
60
|
+
document.addEventListener('mouseup', handleMouseUp);
|
|
61
|
+
},
|
|
62
|
+
[direction, onRatioChange]
|
|
63
|
+
);
|
|
64
|
+
|
|
65
|
+
const isVerticalSplit = direction === 'vertical';
|
|
66
|
+
|
|
67
|
+
return (
|
|
68
|
+
<div
|
|
69
|
+
className={`
|
|
70
|
+
split-handle
|
|
71
|
+
${isVerticalSplit ? 'w-1 h-full cursor-col-resize' : 'h-1 w-full cursor-row-resize'}
|
|
72
|
+
transition-colors
|
|
73
|
+
relative
|
|
74
|
+
`}
|
|
75
|
+
style={{ background: 'var(--os-border-default)' }}
|
|
76
|
+
onMouseEnter={(e) => {
|
|
77
|
+
(e.currentTarget as HTMLElement).style.background = 'var(--os-split-handle-accent)';
|
|
78
|
+
}}
|
|
79
|
+
onMouseLeave={(e) => {
|
|
80
|
+
(e.currentTarget as HTMLElement).style.background = 'var(--os-border-default)';
|
|
81
|
+
}}
|
|
82
|
+
onMouseDown={handleMouseDown}
|
|
83
|
+
>
|
|
84
|
+
{/* Visual indicator on hover */}
|
|
85
|
+
<div
|
|
86
|
+
className="absolute inset-0 opacity-0 hover:opacity-20 transition-opacity"
|
|
87
|
+
style={{ background: 'var(--os-split-handle-accent)' }}
|
|
88
|
+
/>
|
|
89
|
+
</div>
|
|
90
|
+
);
|
|
91
|
+
}
|