painapple-code 1.0.0__py3-none-any.whl
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.
- painapple_code/__init__.py +30 -0
- painapple_code/__main__.py +18 -0
- painapple_code/_version.py +24 -0
- painapple_code/auth_middleware.py +930 -0
- painapple_code/cli/__init__.py +204 -0
- painapple_code/cli/deploy/__init__.py +15 -0
- painapple_code/cli/deploy/claude_seed.py +41 -0
- painapple_code/cli/deploy/config.py +260 -0
- painapple_code/cli/deploy/container.py +715 -0
- painapple_code/cli/deploy/launch.py +163 -0
- painapple_code/cli/deploy/runtime.py +223 -0
- painapple_code/cli/lifecycle_cmd.py +443 -0
- painapple_code/cli/list_cmd.py +572 -0
- painapple_code/cli/manage_cmd.py +670 -0
- painapple_code/cli/netinfo.py +125 -0
- painapple_code/cli/profiles.py +390 -0
- painapple_code/cli/serve_args.py +163 -0
- painapple_code/cli/serve_config.py +256 -0
- painapple_code/cli/setup_cmd.py +664 -0
- painapple_code/cli/ui.py +438 -0
- painapple_code/cost_analytics.py +879 -0
- painapple_code/data/models.defaults.yaml +18 -0
- painapple_code/data/models.yaml +18 -0
- painapple_code/data/presets.defaults.json +32 -0
- painapple_code/data/strings.yaml +2269 -0
- painapple_code/helpers.py +406 -0
- painapple_code/paths.py +1514 -0
- painapple_code/prompt_explorer.py +984 -0
- painapple_code/providers/__init__.py +224 -0
- painapple_code/providers/base.py +710 -0
- painapple_code/providers/claude/__init__.py +74 -0
- painapple_code/providers/claude/capabilities.py +218 -0
- painapple_code/providers/claude/errors.py +79 -0
- painapple_code/providers/claude/launch.py +50 -0
- painapple_code/providers/claude/summary.py +89 -0
- painapple_code/providers/claude/translate.py +74 -0
- painapple_code/providers/claude_sdk/__init__.py +139 -0
- painapple_code/providers/claude_sdk/driver.py +412 -0
- painapple_code/providers/codex/__init__.py +87 -0
- painapple_code/providers/codex/capabilities.py +321 -0
- painapple_code/providers/codex/errors.py +70 -0
- painapple_code/providers/codex/launch.py +142 -0
- painapple_code/providers/codex/summary.py +196 -0
- painapple_code/providers/codex/translate.py +204 -0
- painapple_code/providers/codex_app_server/__init__.py +100 -0
- painapple_code/providers/codex_app_server/capabilities.py +41 -0
- painapple_code/providers/codex_app_server/launch.py +202 -0
- painapple_code/providers/codex_app_server/summary.py +291 -0
- painapple_code/providers/codex_app_server/translate.py +263 -0
- painapple_code/providers/codex_app_server/transport.py +238 -0
- painapple_code/routes/__init__.py +0 -0
- painapple_code/routes/api_agents.py +441 -0
- painapple_code/routes/api_app.py +368 -0
- painapple_code/routes/api_app_commit_sections.py +127 -0
- painapple_code/routes/api_app_config.py +997 -0
- painapple_code/routes/api_app_session_prefs.py +217 -0
- painapple_code/routes/api_browser.py +690 -0
- painapple_code/routes/api_commands.py +374 -0
- painapple_code/routes/api_costs.py +128 -0
- painapple_code/routes/api_drafts.py +139 -0
- painapple_code/routes/api_exec.py +149 -0
- painapple_code/routes/api_files.py +542 -0
- painapple_code/routes/api_git.py +657 -0
- painapple_code/routes/api_logs.py +649 -0
- painapple_code/routes/api_plugins.py +275 -0
- painapple_code/routes/api_project_config.py +418 -0
- painapple_code/routes/api_prompts.py +231 -0
- painapple_code/routes/api_providers.py +53 -0
- painapple_code/routes/api_search.py +411 -0
- painapple_code/routes/api_session_stash.py +234 -0
- painapple_code/routes/api_session_welcome.py +151 -0
- painapple_code/routes/api_sessions.py +742 -0
- painapple_code/routes/api_shadow.py +323 -0
- painapple_code/routes/api_shadow_db.py +512 -0
- painapple_code/routes/api_shadow_files.py +332 -0
- painapple_code/routes/api_shadow_search.py +476 -0
- painapple_code/routes/api_skills.py +610 -0
- painapple_code/routes/api_tasks.py +147 -0
- painapple_code/routes/api_terminal.py +540 -0
- painapple_code/routes/api_upload.py +293 -0
- painapple_code/routes/api_viewer.py +229 -0
- painapple_code/routes/dependencies.py +109 -0
- painapple_code/routes/ws_chat.py +837 -0
- painapple_code/server.py +2208 -0
- painapple_code/server_logging.py +263 -0
- painapple_code/services/__init__.py +0 -0
- painapple_code/services/agent_session.py +2738 -0
- painapple_code/session_store.py +253 -0
- painapple_code/session_store_core.py +879 -0
- painapple_code/shadow_db.py +830 -0
- painapple_code/shadow_db_plans.py +369 -0
- painapple_code/shadow_db_queries.py +595 -0
- painapple_code/shadow_db_schema.py +676 -0
- painapple_code/shadow_git.py +1296 -0
- painapple_code/shadow_git_frontmatter.py +314 -0
- painapple_code/shadow_git_sections.py +520 -0
- painapple_code/shadow_git_summary.py +462 -0
- painapple_code/shadow_parser.py +560 -0
- painapple_code/static/css/00-variables.css +276 -0
- painapple_code/static/css/01-base.css +245 -0
- painapple_code/static/css/02-tooltips.css +36 -0
- painapple_code/static/css/09-left-rail.css +448 -0
- painapple_code/static/css/10-header.css +777 -0
- painapple_code/static/css/11-connection-bar.css +120 -0
- painapple_code/static/css/20-messages.css +750 -0
- painapple_code/static/css/21-tool-blocks.css +2340 -0
- painapple_code/static/css/22-edit-diff.css +619 -0
- painapple_code/static/css/23-thinking.css +1584 -0
- painapple_code/static/css/24-background-tasks.css +359 -0
- painapple_code/static/css/25-context-block.css +252 -0
- painapple_code/static/css/26-session-setup.css +144 -0
- painapple_code/static/css/30-markdown.css +854 -0
- painapple_code/static/css/40-input.css +2418 -0
- painapple_code/static/css/41-autocomplete.css +127 -0
- painapple_code/static/css/42-activity-strip.css +97 -0
- painapple_code/static/css/43-file-autocomplete.css +121 -0
- painapple_code/static/css/44-snippets-autocomplete.css +224 -0
- painapple_code/static/css/45-selection-bar.css +433 -0
- painapple_code/static/css/46-keyboard-bar.css +119 -0
- painapple_code/static/css/47-keyboard-longpress.css +116 -0
- painapple_code/static/css/47-token-profile.css +106 -0
- painapple_code/static/css/50-welcome.css +3095 -0
- painapple_code/static/css/51-modals.css +301 -0
- painapple_code/static/css/52-utilities.css +77 -0
- painapple_code/static/css/53-image-upload.css +456 -0
- painapple_code/static/css/54-todo-list.css +102 -0
- painapple_code/static/css/55-file-upload.css +113 -0
- painapple_code/static/css/56-chat-navigator.css +218 -0
- painapple_code/static/css/56-permission-card.css +332 -0
- painapple_code/static/css/56-plan-approval.css +204 -0
- painapple_code/static/css/56-question-form.css +602 -0
- painapple_code/static/css/57-swipe-indicator.css +95 -0
- painapple_code/static/css/58-session-families.css +627 -0
- painapple_code/static/css/59-about.css +196 -0
- painapple_code/static/css/61-log-explorer.css +723 -0
- painapple_code/static/css/62-json-tree.css +489 -0
- painapple_code/static/css/62-skills-widget.css +650 -0
- painapple_code/static/css/63-commands-widget.css +30 -0
- painapple_code/static/css/63-plugins-widget.css +174 -0
- painapple_code/static/css/63-terminal.css +516 -0
- painapple_code/static/css/64-search-files-widget.css +448 -0
- painapple_code/static/css/65-snippets-widget.css +426 -0
- painapple_code/static/css/66-active-sessions.css +979 -0
- painapple_code/static/css/66-agents-widget.css +265 -0
- painapple_code/static/css/66-file-explorer-widget.css +855 -0
- painapple_code/static/css/66-git-panel.css +791 -0
- painapple_code/static/css/66-history-explorer.css +1496 -0
- painapple_code/static/css/67-diff-viewer.css +602 -0
- painapple_code/static/css/67-editor-view.css +748 -0
- painapple_code/static/css/67-file-preview-widget.css +1873 -0
- painapple_code/static/css/68-browser-widget.css +337 -0
- painapple_code/static/css/68-compare-wizard.css +355 -0
- painapple_code/static/css/68-csv-preview.css +106 -0
- painapple_code/static/css/68-diff-preview.css +103 -0
- painapple_code/static/css/68-helpers-install-widget.css +491 -0
- painapple_code/static/css/68-image-annotate.css +346 -0
- painapple_code/static/css/68-terminal-view.css +98 -0
- painapple_code/static/css/68-uploads-widget.css +229 -0
- painapple_code/static/css/69-cost-analytics.css +537 -0
- painapple_code/static/css/70-config-panel.css +3736 -0
- painapple_code/static/css/70-excalidraw.css +136 -0
- painapple_code/static/css/70-prompt-explorer.css +890 -0
- painapple_code/static/css/71-chart.css +111 -0
- painapple_code/static/css/72-discussion-sidebar.css +678 -0
- painapple_code/static/css/74-debug-logs.css +656 -0
- painapple_code/static/css/80-open-dialog.css +146 -0
- painapple_code/static/css/80-quick-switcher.css +383 -0
- painapple_code/static/css/80-widget-system.css +1763 -0
- painapple_code/static/css/81-quick-actions.css +1020 -0
- painapple_code/static/css/82-context-popover.css +1233 -0
- painapple_code/static/css/83-context-menu.css +339 -0
- painapple_code/static/css/85-lazy-loading.css +102 -0
- painapple_code/static/css/86-zen-mode.css +708 -0
- painapple_code/static/css/87-grid-switcher.css +346 -0
- painapple_code/static/css/login.css +198 -0
- painapple_code/static/dist/app.bundle.js +7177 -0
- painapple_code/static/feature-triage.html +446 -0
- painapple_code/static/icons/apple-touch-icon.png +0 -0
- painapple_code/static/icons/favicon-32.png +0 -0
- painapple_code/static/icons/favicon.ico +0 -0
- painapple_code/static/icons/icon-128.png +0 -0
- painapple_code/static/icons/icon-144.png +0 -0
- painapple_code/static/icons/icon-152.png +0 -0
- painapple_code/static/icons/icon-180.png +0 -0
- painapple_code/static/icons/icon-192.png +0 -0
- painapple_code/static/icons/icon-384.png +0 -0
- painapple_code/static/icons/icon-512.png +0 -0
- painapple_code/static/icons/icon-72.png +0 -0
- painapple_code/static/icons/icon-96.png +0 -0
- painapple_code/static/js/action-delegate.js +187 -0
- painapple_code/static/js/activity-strip.js +193 -0
- painapple_code/static/js/app/context-menus.js +619 -0
- painapple_code/static/js/app/file-preview.js +163 -0
- painapple_code/static/js/app/init-ui.js +574 -0
- painapple_code/static/js/app/input-events.js +561 -0
- painapple_code/static/js/app/input-resize.js +313 -0
- painapple_code/static/js/app/message-actions.js +419 -0
- painapple_code/static/js/app/panels.js +210 -0
- painapple_code/static/js/app/rendering-delegators.js +246 -0
- painapple_code/static/js/app/server-loading.js +365 -0
- painapple_code/static/js/app/session-ops.js +287 -0
- painapple_code/static/js/app/session-switch.js +443 -0
- painapple_code/static/js/app/settings-helpers.js +280 -0
- painapple_code/static/js/app/startup-welcome.js +290 -0
- painapple_code/static/js/app-context.js +157 -0
- painapple_code/static/js/app.js +2134 -0
- painapple_code/static/js/auth-fetch.js +46 -0
- painapple_code/static/js/background-tasks.js +274 -0
- painapple_code/static/js/boot.js +73 -0
- painapple_code/static/js/caret-position.js +171 -0
- painapple_code/static/js/chat-navigator.js +710 -0
- painapple_code/static/js/chat-search.js +323 -0
- painapple_code/static/js/command-executor.js +176 -0
- painapple_code/static/js/command-store.js +497 -0
- painapple_code/static/js/components.js +1414 -0
- painapple_code/static/js/config.js +218 -0
- painapple_code/static/js/context-menu.js +740 -0
- painapple_code/static/js/controllers/chat-controller.js +4252 -0
- painapple_code/static/js/controllers/dialog-controller.js +217 -0
- painapple_code/static/js/controllers/tab-controller.js +1828 -0
- painapple_code/static/js/controllers/thinking-controller.js +915 -0
- painapple_code/static/js/diff-utils.js +486 -0
- painapple_code/static/js/drafts-pill.js +95 -0
- painapple_code/static/js/editor-view.js +815 -0
- painapple_code/static/js/effort-settings.js +430 -0
- painapple_code/static/js/feature-triage.js +811 -0
- painapple_code/static/js/file-autocomplete.js +516 -0
- painapple_code/static/js/file-tabs.js +123 -0
- painapple_code/static/js/gestures.js +448 -0
- painapple_code/static/js/grid-switcher.js +334 -0
- painapple_code/static/js/input-handler.js +1472 -0
- painapple_code/static/js/keyboard-bar.js +495 -0
- painapple_code/static/js/layout-switcher.js +109 -0
- painapple_code/static/js/linkify-utils.js +200 -0
- painapple_code/static/js/login.js +141 -0
- painapple_code/static/js/open-dialog.js +879 -0
- painapple_code/static/js/orphan-terminals.js +373 -0
- painapple_code/static/js/path-utils.js +179 -0
- painapple_code/static/js/perf-marks.js +102 -0
- painapple_code/static/js/permission-settings.js +475 -0
- painapple_code/static/js/pointer-input.js +59 -0
- painapple_code/static/js/preview/json-tree-menu.js +226 -0
- painapple_code/static/js/preview/json-tree-toolbar.js +291 -0
- painapple_code/static/js/preview/json-tree.js +457 -0
- painapple_code/static/js/preview/preview-breadcrumb.js +387 -0
- painapple_code/static/js/preview/preview-edit.js +367 -0
- painapple_code/static/js/preview/preview-events.js +361 -0
- painapple_code/static/js/preview/preview-history.js +567 -0
- painapple_code/static/js/preview/preview-inline-edit.js +869 -0
- painapple_code/static/js/preview/preview-poll.js +108 -0
- painapple_code/static/js/preview/preview-render.js +572 -0
- painapple_code/static/js/preview/preview-search.js +421 -0
- painapple_code/static/js/preview/preview-state.js +256 -0
- painapple_code/static/js/preview/preview-utils.js +251 -0
- painapple_code/static/js/preview-plugins/chart-plugin.js +22 -0
- painapple_code/static/js/preview-plugins/csv-plugin.js +188 -0
- painapple_code/static/js/preview-plugins/diff-plugin.js +258 -0
- painapple_code/static/js/preview-plugins/excalidraw-plugin.js +25 -0
- painapple_code/static/js/preview-plugins/html-plugin.js +104 -0
- painapple_code/static/js/preview-plugins/image-plugin.js +20 -0
- painapple_code/static/js/preview-plugins/index.js +34 -0
- painapple_code/static/js/preview-plugins/json-plugin.js +99 -0
- painapple_code/static/js/preview-plugins/jsonl-plugin.js +152 -0
- painapple_code/static/js/preview-plugins/markdown-plugin.js +266 -0
- painapple_code/static/js/preview-plugins/panzoom-plugin.js +63 -0
- painapple_code/static/js/preview-plugins/plugin-helpers.js +282 -0
- painapple_code/static/js/project-color-picker.js +110 -0
- painapple_code/static/js/project-colors.js +163 -0
- painapple_code/static/js/prompt-favorites.js +187 -0
- painapple_code/static/js/quick-actions-menu.js +1736 -0
- painapple_code/static/js/quick-actions-registry.js +1239 -0
- painapple_code/static/js/quick-switcher/controller.js +294 -0
- painapple_code/static/js/quick-switcher/fuzzy-scorer.js +123 -0
- painapple_code/static/js/quick-switcher/index.js +30 -0
- painapple_code/static/js/quick-switcher/providers/base-provider.js +51 -0
- painapple_code/static/js/quick-switcher/providers/command-provider.js +56 -0
- painapple_code/static/js/quick-switcher/providers/file-provider.js +291 -0
- painapple_code/static/js/quick-switcher/providers/panel-provider.js +132 -0
- painapple_code/static/js/quick-switcher/providers/project-provider.js +361 -0
- painapple_code/static/js/quick-switcher/providers/read-files-provider.js +104 -0
- painapple_code/static/js/quick-switcher/providers/skills-provider.js +129 -0
- painapple_code/static/js/quick-switcher/registry.js +69 -0
- painapple_code/static/js/quick-switcher/ui/item.js +54 -0
- painapple_code/static/js/quick-switcher/ui/picker.js +464 -0
- painapple_code/static/js/recent-opens.js +40 -0
- painapple_code/static/js/scroll-manager.js +530 -0
- painapple_code/static/js/scroll-state-machine.js +359 -0
- painapple_code/static/js/selection/action-bar.js +793 -0
- painapple_code/static/js/selection/index.js +15 -0
- painapple_code/static/js/selection/selection-handler.js +877 -0
- painapple_code/static/js/selection/state.js +181 -0
- painapple_code/static/js/session/agent-progress.js +202 -0
- painapple_code/static/js/session/handle-agent-message.js +481 -0
- painapple_code/static/js/session/handle-message.js +550 -0
- painapple_code/static/js/session/interactive.js +587 -0
- painapple_code/static/js/session/message-store.js +271 -0
- painapple_code/static/js/session/messages.js +253 -0
- painapple_code/static/js/session/persistence.js +179 -0
- painapple_code/static/js/session/restore.js +136 -0
- painapple_code/static/js/session/sync.js +333 -0
- painapple_code/static/js/session-container-pool.js +371 -0
- painapple_code/static/js/session-setup-panel.js +274 -0
- painapple_code/static/js/session.js +1047 -0
- painapple_code/static/js/shortcut-hints.js +272 -0
- painapple_code/static/js/shortcuts.js +1146 -0
- painapple_code/static/js/skills-autocomplete.js +262 -0
- painapple_code/static/js/snippets-autocomplete.js +738 -0
- painapple_code/static/js/stash-refs-view.js +83 -0
- painapple_code/static/js/stash-ui.js +768 -0
- painapple_code/static/js/stash.js +785 -0
- painapple_code/static/js/status-bar.js +1184 -0
- painapple_code/static/js/token-profile.js +274 -0
- painapple_code/static/js/tool-renderer-blocks.js +1626 -0
- painapple_code/static/js/tool-renderer-thinking.js +813 -0
- painapple_code/static/js/tool-renderer.js +1500 -0
- painapple_code/static/js/tooltips.js +197 -0
- painapple_code/static/js/upload-manager.js +1019 -0
- painapple_code/static/js/utils.js +521 -0
- painapple_code/static/js/welcome/api.js +363 -0
- painapple_code/static/js/welcome/cards.js +626 -0
- painapple_code/static/js/welcome/context-menu.js +585 -0
- painapple_code/static/js/welcome/families.js +801 -0
- painapple_code/static/js/welcome/preview.js +234 -0
- painapple_code/static/js/welcome/state.js +129 -0
- painapple_code/static/js/welcome.js +1874 -0
- painapple_code/static/js/widget-system/base-widget.js +717 -0
- painapple_code/static/js/widget-system/device-manager.js +178 -0
- painapple_code/static/js/widget-system/event-bus.js +152 -0
- painapple_code/static/js/widget-system/icons.js +120 -0
- painapple_code/static/js/widget-system/index.js +49 -0
- painapple_code/static/js/widget-system/init.js +54 -0
- painapple_code/static/js/widget-system/types/bottom-sheet.js +307 -0
- painapple_code/static/js/widget-system/types/floating.js +915 -0
- painapple_code/static/js/widget-system/types/index.js +40 -0
- painapple_code/static/js/widget-system/types/modal.js +147 -0
- painapple_code/static/js/widget-system/types/sidebar.js +158 -0
- painapple_code/static/js/widget-system/types/tab.js +146 -0
- painapple_code/static/js/widget-system/types/top-sheet.js +203 -0
- painapple_code/static/js/widget-system/widget-manager.js +1187 -0
- painapple_code/static/js/widgets/about-widget.js +260 -0
- painapple_code/static/js/widgets/active-sessions-widget.js +760 -0
- painapple_code/static/js/widgets/agents-widget.js +897 -0
- painapple_code/static/js/widgets/browser-widget.js +579 -0
- painapple_code/static/js/widgets/commands-widget.js +694 -0
- painapple_code/static/js/widgets/config/commit-sections.js +449 -0
- painapple_code/static/js/widgets/config/dir-autocomplete.js +224 -0
- painapple_code/static/js/widgets/config/gestures.js +161 -0
- painapple_code/static/js/widgets/config/models-tab.js +988 -0
- painapple_code/static/js/widgets/config/quick-actions-tab.js +882 -0
- painapple_code/static/js/widgets/config/shortcut-editor.js +587 -0
- painapple_code/static/js/widgets/config/state.js +517 -0
- painapple_code/static/js/widgets/config/system-controls.js +90 -0
- painapple_code/static/js/widgets/config-widget.js +1535 -0
- painapple_code/static/js/widgets/cost-analytics-widget.js +621 -0
- painapple_code/static/js/widgets/debug-widget.js +693 -0
- painapple_code/static/js/widgets/diff-viewer-widget.js +1420 -0
- painapple_code/static/js/widgets/discussion-widget.js +1107 -0
- painapple_code/static/js/widgets/file-explorer-widget.js +2166 -0
- painapple_code/static/js/widgets/file-preview-widget.js +861 -0
- painapple_code/static/js/widgets/git-widget.js +794 -0
- painapple_code/static/js/widgets/helpers-install-widget.js +720 -0
- painapple_code/static/js/widgets/history-explorer-widget.js +1804 -0
- painapple_code/static/js/widgets/image-annotate-widget.js +951 -0
- painapple_code/static/js/widgets/image-preview-widget.js +692 -0
- painapple_code/static/js/widgets/index.js +176 -0
- painapple_code/static/js/widgets/log-explorer-widget.js +1057 -0
- painapple_code/static/js/widgets/plugins-widget.js +305 -0
- painapple_code/static/js/widgets/prompt-explorer-widget.js +1119 -0
- painapple_code/static/js/widgets/search-files-widget.js +529 -0
- painapple_code/static/js/widgets/skills-widget.js +843 -0
- painapple_code/static/js/widgets/snippets-widget.js +479 -0
- painapple_code/static/js/widgets/sub-agents-widget.js +333 -0
- painapple_code/static/js/widgets/tasks-widget.js +379 -0
- painapple_code/static/js/widgets/terminal/connection.js +283 -0
- painapple_code/static/js/widgets/terminal/gestures.js +357 -0
- painapple_code/static/js/widgets/terminal/init.js +642 -0
- painapple_code/static/js/widgets/terminal/link-providers.js +532 -0
- painapple_code/static/js/widgets/terminal/osc52.js +213 -0
- painapple_code/static/js/widgets/terminal/render.js +159 -0
- painapple_code/static/js/widgets/terminal/size.js +54 -0
- painapple_code/static/js/widgets/terminal/state.js +211 -0
- painapple_code/static/js/widgets/terminal/wrap-utils.js +101 -0
- painapple_code/static/js/widgets/terminal-widget.js +793 -0
- painapple_code/static/js/widgets/uploads-widget.js +461 -0
- painapple_code/static/js/widgets/zen-widget.js +1001 -0
- painapple_code/static/login.html +51 -0
- painapple_code/static/manifest.json +73 -0
- painapple_code/static/sw.js +293 -0
- painapple_code/static/vendor/LICENSES.txt +769 -0
- painapple_code/static/vendor/README.md +103 -0
- painapple_code/static/vendor/codemirror.js +35 -0
- painapple_code/static/vendor/codemirror.js.LEGAL.txt +304 -0
- painapple_code/static/vendor/github-markdown-dark.min.css +1124 -0
- painapple_code/static/vendor/highlight-github-dark.min.css +10 -0
- painapple_code/static/vendor/highlight-lang-dockerfile.min.js +8 -0
- painapple_code/static/vendor/highlight-lang-nginx.min.js +21 -0
- painapple_code/static/vendor/highlight-lang-properties.min.js +10 -0
- painapple_code/static/vendor/highlight-lang-scala.min.js +28 -0
- painapple_code/static/vendor/highlight.min.js +1213 -0
- painapple_code/static/vendor/marked.min.js +6 -0
- painapple_code/static/vendor/purify.min.js +3 -0
- painapple_code/static/vendor/xterm-addon-fit.js +2 -0
- painapple_code/static/vendor/xterm.css +209 -0
- painapple_code/static/vendor/xterm.js +2 -0
- painapple_code/static/web-client.html +635 -0
- painapple_code/subprocess_registry.py +535 -0
- painapple_code/tls_cert.py +79 -0
- painapple_code/tools/agents/shadow-git-helper.md +400 -0
- painapple_code/tools/excalidraw-to-svg.js +143 -0
- painapple_code/tools/install-helpers.sh +148 -0
- painapple_code/tools/shadow-git +320 -0
- painapple_code/tools/shadow-query +120 -0
- painapple_code/tools/vegalite-to-svg.js +79 -0
- painapple_code/turn_query.py +766 -0
- painapple_code/turn_tracker.py +221 -0
- painapple_code/utils/__init__.py +0 -0
- painapple_code/utils/agent_cli.py +306 -0
- painapple_code/utils/chart.py +103 -0
- painapple_code/utils/excalidraw.py +107 -0
- painapple_code/utils/file_lock.py +162 -0
- painapple_code/utils/file_paths.py +648 -0
- painapple_code/utils/generate_icons.py +251 -0
- painapple_code/utils/proc.py +205 -0
- painapple_code/utils/pty_backend.py +59 -0
- painapple_code/utils/pty_posix.py +160 -0
- painapple_code/utils/pty_windows.py +245 -0
- painapple_code/utils/token_profiles.py +91 -0
- painapple_code/viewer_templates.py +657 -0
- painapple_code/welcome_search.py +879 -0
- painapple_code-1.0.0.dist-info/METADATA +292 -0
- painapple_code-1.0.0.dist-info/RECORD +436 -0
- painapple_code-1.0.0.dist-info/WHEEL +5 -0
- painapple_code-1.0.0.dist-info/entry_points.txt +2 -0
- painapple_code-1.0.0.dist-info/licenses/LICENSE +661 -0
- painapple_code-1.0.0.dist-info/licenses/THIRD_PARTY_NOTICES.md +261 -0
- painapple_code-1.0.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,2166 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* FileExplorerWidget - File browser
|
|
3
|
+
*
|
|
4
|
+
* Features:
|
|
5
|
+
* - Tree list with expansion + inline filter (recursive via /api/files/list)
|
|
6
|
+
* - Session-aware navigation via breadcrumbs
|
|
7
|
+
* - File type filtering and sort
|
|
8
|
+
* - Keyboard navigation
|
|
9
|
+
* - Context menu (preview, open, copy path, insert to chat, compare)
|
|
10
|
+
*
|
|
11
|
+
* Scope history: tree/bookmarks/recent views + back/forward history +
|
|
12
|
+
* full-screen tab mode were cut 2026-04-19. Separate "search view" mode was
|
|
13
|
+
* cut 2026-04-21 — the inline filter now handles subtree search.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import S from '../strings.js';
|
|
17
|
+
import { escapeHtml, formatDate } from '../utils.js';
|
|
18
|
+
import { basename, dirname, isUnder, joinPath, parentOf, pathRoot, pathSep, relativeTo, resolvePath, splitPath, stripTrailingSep } from '../path-utils.js';
|
|
19
|
+
import { CONFIG } from '../config.js';
|
|
20
|
+
import { WidgetManager, WidgetBus, ICONS } from '../widget-system/index.js';
|
|
21
|
+
import { BrowserWidget } from './browser-widget.js';
|
|
22
|
+
|
|
23
|
+
const GIT_STATUS_LABELS = {
|
|
24
|
+
M: S.widgets.git.statuses.modified,
|
|
25
|
+
A: S.widgets.git.statuses.added,
|
|
26
|
+
D: S.widgets.git.statuses.deleted,
|
|
27
|
+
U: S.widgets.git.statuses.untracked,
|
|
28
|
+
S: S.widgets.git.statuses.staged,
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
// Compact SVG glyphs for the per-file git badge. Stroke=current so they pick
|
|
32
|
+
// up the per-status color from .fe-git-{M,A,D,U,S} in CSS.
|
|
33
|
+
const GIT_STATUS_ICONS = {
|
|
34
|
+
// Modified — pencil
|
|
35
|
+
M: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M12 20h9"/><path d="M16.5 3.5a2.12 2.12 0 0 1 3 3L7 19l-4 1 1-4 12.5-12.5z"/></svg>',
|
|
36
|
+
// Added — plus
|
|
37
|
+
A: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>',
|
|
38
|
+
// Deleted — minus
|
|
39
|
+
D: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round"><line x1="5" y1="12" x2="19" y2="12"/></svg>',
|
|
40
|
+
// Untracked — dotted/open circle (question-mark feel)
|
|
41
|
+
U: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round"><circle cx="12" cy="12" r="8" stroke-dasharray="2 3"/></svg>',
|
|
42
|
+
// Staged — check
|
|
43
|
+
S: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>',
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
// Grouping icons: folder-over-file / file-over-folder / mixed stack
|
|
47
|
+
const GROUP_ICONS = {
|
|
48
|
+
'dirs-first': '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M3 7a1 1 0 0 1 1-1h5l2 2h9a1 1 0 0 1 1 1v3H3V7z"/><rect x="5" y="14" width="14" height="6" rx="1"/></svg>',
|
|
49
|
+
'files-first': '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="5" y="4" width="14" height="6" rx="1"/><path d="M3 14a1 1 0 0 1 1-1h5l2 2h9a1 1 0 0 1 1 1v3H3v-5z"/></svg>',
|
|
50
|
+
'mixed': '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="4" y="4" width="16" height="4" rx="1"/><rect x="4" y="10" width="16" height="4" rx="1"/><rect x="4" y="16" width="16" height="4" rx="1"/></svg>',
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
// Types worth rendering in the browser widget (vs. plain-text Preview):
|
|
54
|
+
// HTML gets the full render pipeline; the rest are iframe-native formats
|
|
55
|
+
const BROWSER_RENDERABLE = /\.(html?|svg|pdf|png|jpe?g|gif|webp|ico)$/i;
|
|
56
|
+
|
|
57
|
+
// ============================================================================
|
|
58
|
+
// State Management (Singleton - persists across widget transforms)
|
|
59
|
+
// ============================================================================
|
|
60
|
+
|
|
61
|
+
class FileExplorerState {
|
|
62
|
+
constructor() {
|
|
63
|
+
this.reset();
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
reset() {
|
|
67
|
+
this.sessionId = null;
|
|
68
|
+
this.cwd = null;
|
|
69
|
+
this.currentPath = null;
|
|
70
|
+
this.files = [];
|
|
71
|
+
this.loading = false;
|
|
72
|
+
this.error = null;
|
|
73
|
+
// Structured error for the current top-level listing: { status, detail, path }
|
|
74
|
+
this.errorInfo = null;
|
|
75
|
+
// Per-subtree expansion errors: Map<absPath, { status, detail }>
|
|
76
|
+
this.childrenError = new Map();
|
|
77
|
+
|
|
78
|
+
// Selection — absPath of the keyboard-selected item
|
|
79
|
+
this.selectedPath = null;
|
|
80
|
+
|
|
81
|
+
// Filters
|
|
82
|
+
this.showHidden = false;
|
|
83
|
+
this.filter = 'all'; // 'all' | 'code' | 'images' | 'docs' | 'folders'
|
|
84
|
+
|
|
85
|
+
// Inline filter (becomes a recursive search once query is ≥2 chars)
|
|
86
|
+
this.filterBarOpen = false;
|
|
87
|
+
this.filterQuery = '';
|
|
88
|
+
|
|
89
|
+
// Recursive tree index for subtree search (populated lazily per-cwd).
|
|
90
|
+
// Holds absolute paths of every file under the cwd (from /api/files/list).
|
|
91
|
+
this.treeIndex = null;
|
|
92
|
+
this.treeIndexCwd = null;
|
|
93
|
+
this.treeIndexLoading = false;
|
|
94
|
+
this.treeIndexError = null;
|
|
95
|
+
this.treeIndexTruncated = false;
|
|
96
|
+
// Cache key fragment that captures the index-affecting query params, so
|
|
97
|
+
// toggling "include ignored" forces a refetch instead of reusing stale.
|
|
98
|
+
this.treeIndexKey = null;
|
|
99
|
+
|
|
100
|
+
// Search behavior toggles (filter bar)
|
|
101
|
+
this.searchSubdirs = true; // false = only direct children of cwd
|
|
102
|
+
this.searchIncludeIgnored = false; // true = include .gitignore'd + hidden
|
|
103
|
+
|
|
104
|
+
// Breadcrumb path editor — typed path input swaps in for the segments
|
|
105
|
+
this.pathEditing = false;
|
|
106
|
+
|
|
107
|
+
// Git status — Map<absPath, 'M' | 'A' | 'D' | 'U' | 'S'> (modified/added/deleted/untracked/staged)
|
|
108
|
+
this.gitStatus = new Map();
|
|
109
|
+
this.gitBranch = null;
|
|
110
|
+
this.gitFetchedForCwd = null;
|
|
111
|
+
|
|
112
|
+
// Tree expansion — Set<absPath> for expanded dirs, Map<absPath, Files[]> for cached children
|
|
113
|
+
this.expanded = new Set();
|
|
114
|
+
this.childrenCache = new Map();
|
|
115
|
+
this.loadingChildren = new Set();
|
|
116
|
+
|
|
117
|
+
// Widget reference
|
|
118
|
+
this.widget = null;
|
|
119
|
+
this.currentContainer = null;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// Navigation — breadcrumbs only (back/forward history removed 2026-04-19)
|
|
123
|
+
navigateTo(path) {
|
|
124
|
+
this.currentPath = path;
|
|
125
|
+
this.selectedPath = null;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// Per-session state map
|
|
130
|
+
const states = new Map();
|
|
131
|
+
|
|
132
|
+
function getState(sessionId) {
|
|
133
|
+
if (!sessionId) sessionId = WidgetManager.currentSessionId;
|
|
134
|
+
if (!states.has(sessionId)) {
|
|
135
|
+
const s = new FileExplorerState();
|
|
136
|
+
states.set(sessionId, s);
|
|
137
|
+
}
|
|
138
|
+
return states.get(sessionId);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function destroyState(sessionId) {
|
|
142
|
+
states.delete(sessionId);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// ============================================================================
|
|
146
|
+
// Global Sort Preferences (persisted across sessions and reloads)
|
|
147
|
+
// ============================================================================
|
|
148
|
+
|
|
149
|
+
const SORT_PREFS_KEY = 'file-explorer-sort';
|
|
150
|
+
const SORT_FIELDS = ['name', 'modified', 'size', 'kind'];
|
|
151
|
+
const GROUP_MODES = ['dirs-first', 'files-first', 'mixed'];
|
|
152
|
+
|
|
153
|
+
function loadSortPrefs() {
|
|
154
|
+
try {
|
|
155
|
+
const raw = localStorage.getItem(SORT_PREFS_KEY);
|
|
156
|
+
if (raw) {
|
|
157
|
+
const p = JSON.parse(raw);
|
|
158
|
+
const hasField = SORT_FIELDS.includes(p.sortBy);
|
|
159
|
+
const hasDir = typeof p.sortAsc === 'boolean';
|
|
160
|
+
const group = GROUP_MODES.includes(p.groupDirs) ? p.groupDirs : 'dirs-first';
|
|
161
|
+
if (hasField && hasDir) {
|
|
162
|
+
return { sortBy: p.sortBy, sortAsc: p.sortAsc, groupDirs: group };
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
} catch {}
|
|
166
|
+
return { sortBy: 'name', sortAsc: true, groupDirs: 'dirs-first' };
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
let sortPrefs = loadSortPrefs();
|
|
170
|
+
|
|
171
|
+
function setSortPrefs(next) {
|
|
172
|
+
sortPrefs = { ...sortPrefs, ...next };
|
|
173
|
+
try {
|
|
174
|
+
localStorage.setItem(SORT_PREFS_KEY, JSON.stringify(sortPrefs));
|
|
175
|
+
} catch {}
|
|
176
|
+
renderContent();
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// ============================================================================
|
|
180
|
+
// File Type Utilities
|
|
181
|
+
// ============================================================================
|
|
182
|
+
|
|
183
|
+
const FILE_TYPES = {
|
|
184
|
+
code: {
|
|
185
|
+
extensions: ['.js', '.ts', '.jsx', '.tsx', '.py', '.rb', '.go', '.rs', '.java', '.c', '.cpp', '.h', '.hpp', '.cs', '.php', '.swift', '.kt', '.scala', '.vue', '.svelte', '.css', '.scss', '.sass', '.less', '.html', '.xml', '.json', '.yaml', '.yml', '.toml', '.sh', '.bash', '.zsh', '.fish', '.sql', '.graphql', '.proto'],
|
|
186
|
+
icon: 'code',
|
|
187
|
+
color: 'var(--accent-blue)',
|
|
188
|
+
label: 'Code'
|
|
189
|
+
},
|
|
190
|
+
images: {
|
|
191
|
+
extensions: ['.png', '.jpg', '.jpeg', '.gif', '.webp', '.svg', '.ico', '.bmp', '.tiff', '.heic', '.avif'],
|
|
192
|
+
icon: 'image',
|
|
193
|
+
color: 'var(--accent-purple)',
|
|
194
|
+
label: 'Image'
|
|
195
|
+
},
|
|
196
|
+
docs: {
|
|
197
|
+
extensions: ['.md', '.markdown', '.mdx', '.txt', '.doc', '.docx', '.pdf', '.rtf', '.tex', '.org', '.rst'],
|
|
198
|
+
icon: 'file-text',
|
|
199
|
+
color: 'var(--accent-green)',
|
|
200
|
+
label: 'Doc'
|
|
201
|
+
},
|
|
202
|
+
data: {
|
|
203
|
+
extensions: ['.csv', '.tsv', '.xls', '.xlsx', '.db', '.sqlite', '.parquet', '.arrow'],
|
|
204
|
+
icon: 'table',
|
|
205
|
+
color: 'var(--accent-orange)',
|
|
206
|
+
label: 'Data'
|
|
207
|
+
},
|
|
208
|
+
config: {
|
|
209
|
+
extensions: ['.env', '.gitignore', '.dockerignore', '.editorconfig', '.eslintrc', '.prettierrc', '.babelrc', 'Makefile', 'Dockerfile', '.npmrc', '.nvmrc'],
|
|
210
|
+
icon: 'settings',
|
|
211
|
+
color: 'var(--text-tertiary)',
|
|
212
|
+
label: 'Config'
|
|
213
|
+
},
|
|
214
|
+
archive: {
|
|
215
|
+
extensions: ['.zip', '.tar', '.gz', '.bz2', '.xz', '.7z', '.rar'],
|
|
216
|
+
icon: 'archive',
|
|
217
|
+
color: 'var(--accent-yellow)',
|
|
218
|
+
label: 'Archive'
|
|
219
|
+
}
|
|
220
|
+
};
|
|
221
|
+
|
|
222
|
+
function getFileType(filename) {
|
|
223
|
+
const lower = filename.toLowerCase();
|
|
224
|
+
for (const [type, config] of Object.entries(FILE_TYPES)) {
|
|
225
|
+
if (config.extensions.some(ext => lower.endsWith(ext) || lower === ext.slice(1))) {
|
|
226
|
+
return { type, ...config };
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
return { type: 'other', icon: 'file', color: 'var(--text-secondary)', label: 'File' };
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
// Per-extension Kind labels — Finder-style. The bucket in FILE_TYPES still
|
|
233
|
+
// drives the row icon and color (so all source files share the "code" icon),
|
|
234
|
+
// but the *label* in the Kind column is specific. Anything not listed falls
|
|
235
|
+
// back to the uppercased extension (e.g. ".log" → "LOG"), which handles the
|
|
236
|
+
// long tail gracefully.
|
|
237
|
+
const EXTENSION_LABELS = {
|
|
238
|
+
// Markup / config / data
|
|
239
|
+
'.html': 'HTML', '.htm': 'HTML',
|
|
240
|
+
'.xml': 'XML',
|
|
241
|
+
'.json': 'JSON', '.jsonl': 'JSONL',
|
|
242
|
+
'.yaml': 'YAML', '.yml': 'YAML',
|
|
243
|
+
'.toml': 'TOML',
|
|
244
|
+
'.csv': 'CSV', '.tsv': 'TSV',
|
|
245
|
+
'.md': 'Markdown', '.markdown': 'Markdown', '.mdx': 'MDX',
|
|
246
|
+
'.txt': 'Text', '.rtf': 'Rich Text',
|
|
247
|
+
'.pdf': 'PDF',
|
|
248
|
+
// Source code
|
|
249
|
+
'.js': 'JavaScript', '.mjs': 'JavaScript', '.cjs': 'JavaScript',
|
|
250
|
+
'.ts': 'TypeScript',
|
|
251
|
+
'.jsx': 'JSX', '.tsx': 'TSX',
|
|
252
|
+
'.py': 'Python',
|
|
253
|
+
'.rb': 'Ruby',
|
|
254
|
+
'.go': 'Go',
|
|
255
|
+
'.rs': 'Rust',
|
|
256
|
+
'.java': 'Java',
|
|
257
|
+
'.c': 'C', '.h': 'C Header',
|
|
258
|
+
'.cpp': 'C++', '.hpp': 'C++ Header',
|
|
259
|
+
'.cs': 'C#',
|
|
260
|
+
'.php': 'PHP',
|
|
261
|
+
'.swift': 'Swift',
|
|
262
|
+
'.kt': 'Kotlin',
|
|
263
|
+
'.scala': 'Scala',
|
|
264
|
+
'.vue': 'Vue', '.svelte': 'Svelte',
|
|
265
|
+
'.css': 'CSS', '.scss': 'SCSS', '.sass': 'Sass', '.less': 'Less',
|
|
266
|
+
'.sh': 'Shell', '.bash': 'Shell', '.zsh': 'Shell', '.fish': 'Shell',
|
|
267
|
+
'.sql': 'SQL',
|
|
268
|
+
'.graphql': 'GraphQL', '.proto': 'Protobuf',
|
|
269
|
+
// Images
|
|
270
|
+
'.png': 'PNG',
|
|
271
|
+
'.jpg': 'JPEG', '.jpeg': 'JPEG',
|
|
272
|
+
'.gif': 'GIF',
|
|
273
|
+
'.webp': 'WebP',
|
|
274
|
+
'.svg': 'SVG',
|
|
275
|
+
'.ico': 'Icon',
|
|
276
|
+
'.bmp': 'Bitmap',
|
|
277
|
+
'.tiff': 'TIFF',
|
|
278
|
+
'.heic': 'HEIC',
|
|
279
|
+
'.avif': 'AVIF',
|
|
280
|
+
// Data / archive
|
|
281
|
+
'.xls': 'Excel', '.xlsx': 'Excel',
|
|
282
|
+
'.db': 'Database', '.sqlite': 'SQLite',
|
|
283
|
+
'.parquet': 'Parquet', '.arrow': 'Arrow',
|
|
284
|
+
'.zip': 'ZIP', '.tar': 'TAR',
|
|
285
|
+
'.gz': 'Gzip', '.bz2': 'Bzip2', '.xz': 'XZ',
|
|
286
|
+
'.7z': '7-Zip', '.rar': 'RAR',
|
|
287
|
+
};
|
|
288
|
+
|
|
289
|
+
// User-facing label for the Kind column. Folders get "Folder"; otherwise try
|
|
290
|
+
// per-extension first, then the uppercased extension, then the bucket label
|
|
291
|
+
// for no-extension files like "Makefile" / "Dockerfile" (which match the
|
|
292
|
+
// config bucket via full-name extensions).
|
|
293
|
+
function getKindLabel(file) {
|
|
294
|
+
if (file.is_dir) return 'Folder';
|
|
295
|
+
const name = file.name;
|
|
296
|
+
const dot = name.lastIndexOf('.');
|
|
297
|
+
if (dot > 0 && dot < name.length - 1) {
|
|
298
|
+
const ext = name.slice(dot).toLowerCase();
|
|
299
|
+
if (EXTENSION_LABELS[ext]) return EXTENSION_LABELS[ext];
|
|
300
|
+
return name.slice(dot + 1).toUpperCase();
|
|
301
|
+
}
|
|
302
|
+
return getFileType(name).label;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
// Sort key for the Kind column. Folders bucket together regardless of name
|
|
306
|
+
// (independent of the dirs-first grouping toggle). Files sort by their Kind
|
|
307
|
+
// *label* so HTML rows cluster with HTML, JSON with JSON, etc., instead of
|
|
308
|
+
// the previous coarse "all code together" behavior.
|
|
309
|
+
function getKindSortKey(file) {
|
|
310
|
+
if (file.is_dir) return 'folder';
|
|
311
|
+
return getKindLabel(file).toLowerCase();
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
function formatFileSize(bytes) {
|
|
315
|
+
if (bytes === 0) return '0 B';
|
|
316
|
+
if (bytes === undefined || bytes === null) return '';
|
|
317
|
+
const units = ['B', 'KB', 'MB', 'GB'];
|
|
318
|
+
const i = Math.floor(Math.log(bytes) / Math.log(1024));
|
|
319
|
+
return `${(bytes / Math.pow(1024, i)).toFixed(i > 0 ? 1 : 0)} ${units[i]}`;
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
// Friendly user-facing description of a fetchDirectory error. Falls back to
|
|
323
|
+
// the raw detail or status when the server didn't send a recognized string.
|
|
324
|
+
function describeDirError(errInfo) {
|
|
325
|
+
if (!errInfo) return null;
|
|
326
|
+
const { status, detail } = errInfo;
|
|
327
|
+
if (status === 403 || detail === 'Path not allowed') {
|
|
328
|
+
return {
|
|
329
|
+
icon: 'warning',
|
|
330
|
+
title: 'Access denied',
|
|
331
|
+
body: detail === 'Path not allowed'
|
|
332
|
+
? 'This path is outside the allowed roots for this session.'
|
|
333
|
+
: (detail || 'You don\'t have permission to view this folder.'),
|
|
334
|
+
};
|
|
335
|
+
}
|
|
336
|
+
if (status === 404 || detail === 'Path not found') {
|
|
337
|
+
return {
|
|
338
|
+
icon: 'alert',
|
|
339
|
+
title: 'Folder not found',
|
|
340
|
+
body: 'The path no longer exists.',
|
|
341
|
+
};
|
|
342
|
+
}
|
|
343
|
+
if (status === 400 && detail === 'Not a directory') {
|
|
344
|
+
return {
|
|
345
|
+
icon: 'file',
|
|
346
|
+
title: 'Not a folder',
|
|
347
|
+
body: 'This path points to a file, not a directory — try previewing it instead.',
|
|
348
|
+
};
|
|
349
|
+
}
|
|
350
|
+
return {
|
|
351
|
+
icon: 'alert',
|
|
352
|
+
title: 'Couldn\'t load folder',
|
|
353
|
+
body: detail || `Server returned HTTP ${status || '?'}`,
|
|
354
|
+
};
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
function matchesFilter(file, filter) {
|
|
358
|
+
if (filter === 'all') return true;
|
|
359
|
+
if (filter === 'folders') return file.is_dir;
|
|
360
|
+
const fileType = getFileType(file.name);
|
|
361
|
+
return fileType.type === filter;
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
// ============================================================================
|
|
365
|
+
// API Functions
|
|
366
|
+
// ============================================================================
|
|
367
|
+
|
|
368
|
+
async function fetchDirectory(path) {
|
|
369
|
+
const response = await fetch(`/api/files?path=${encodeURIComponent(path)}`);
|
|
370
|
+
if (!response.ok) {
|
|
371
|
+
let detail;
|
|
372
|
+
try { detail = (await response.json())?.detail; } catch {}
|
|
373
|
+
const err = new Error(detail || `HTTP ${response.status}`);
|
|
374
|
+
err.status = response.status;
|
|
375
|
+
err.detail = detail;
|
|
376
|
+
err.path = path;
|
|
377
|
+
throw err;
|
|
378
|
+
}
|
|
379
|
+
const data = await response.json();
|
|
380
|
+
return data.files || [];
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
async function fetchFileContent(path) {
|
|
384
|
+
try {
|
|
385
|
+
const response = await fetch(`/api/file?path=${encodeURIComponent(path)}`);
|
|
386
|
+
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
|
387
|
+
const data = await response.json();
|
|
388
|
+
return data.content;
|
|
389
|
+
} catch (err) {
|
|
390
|
+
console.error('Failed to fetch file:', err);
|
|
391
|
+
throw err;
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
async function fetchGitStatus(cwd) {
|
|
396
|
+
try {
|
|
397
|
+
const response = await fetch(`/api/git/status?cwd=${encodeURIComponent(cwd)}`);
|
|
398
|
+
if (!response.ok) return null;
|
|
399
|
+
const data = await response.json();
|
|
400
|
+
if (data.error === 'not_a_repo') return null;
|
|
401
|
+
return data;
|
|
402
|
+
} catch {
|
|
403
|
+
return null;
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
async function refreshGitStatus(force = false) {
|
|
408
|
+
const state = getState();
|
|
409
|
+
if (!state.cwd) return;
|
|
410
|
+
if (!force && state.gitFetchedForCwd === state.cwd) return;
|
|
411
|
+
const data = await fetchGitStatus(state.cwd);
|
|
412
|
+
state.gitFetchedForCwd = state.cwd;
|
|
413
|
+
state.gitStatus = new Map();
|
|
414
|
+
state.gitBranch = null;
|
|
415
|
+
if (!data) return;
|
|
416
|
+
state.gitBranch = data.branch || null;
|
|
417
|
+
const root = data.root || state.cwd;
|
|
418
|
+
// git always reports '/'-separated paths; resolvePath re-emits them with
|
|
419
|
+
// the server's own separator so these keys match the tree's, which are
|
|
420
|
+
// built the same way. A raw concat left 'C:\proj\src/app.js' — a key
|
|
421
|
+
// nothing ever looked up, so every file showed as unmodified.
|
|
422
|
+
const join = (rel) => resolvePath(root, rel);
|
|
423
|
+
// Precedence: staged > modified > untracked (staged+modified both flagged as 'M' is fine)
|
|
424
|
+
(data.untracked || []).forEach(f => state.gitStatus.set(join(f.path), 'U'));
|
|
425
|
+
(data.modified || []).forEach(f => state.gitStatus.set(join(f.path), f.status === 'deleted' ? 'D' : 'M'));
|
|
426
|
+
(data.staged || []).forEach(f => {
|
|
427
|
+
const map = { added: 'A', deleted: 'D', renamed: 'M', copied: 'M' };
|
|
428
|
+
state.gitStatus.set(join(f.path), map[f.status] || 'S');
|
|
429
|
+
});
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
// Roll up child statuses into a single label for the directory.
|
|
433
|
+
// - any A/M/D/S child → 'M' (the dir contains real changes)
|
|
434
|
+
// - only U children → 'U' (the dir is purely untracked content)
|
|
435
|
+
// Untracked-only dirs render in default color so the user spec
|
|
436
|
+
// ("white = untracked") propagates up to the parent.
|
|
437
|
+
function dirHasGitChanges(dirPath) {
|
|
438
|
+
const state = getState();
|
|
439
|
+
if (state.gitStatus.size === 0) return null;
|
|
440
|
+
let sawAny = false;
|
|
441
|
+
for (const [p, s] of state.gitStatus) {
|
|
442
|
+
if (p === dirPath || !isUnder(p, dirPath)) continue;
|
|
443
|
+
if (s !== 'U') return 'M';
|
|
444
|
+
sawAny = true;
|
|
445
|
+
}
|
|
446
|
+
return sawAny ? 'U' : null;
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
// Lazy-fetch a flat list of every file under the cwd. Used by the inline filter
|
|
450
|
+
// to find matches anywhere in the subtree (not just the loaded/expanded nodes).
|
|
451
|
+
// Cached per-cwd; invalidated on Refresh and on cwd change.
|
|
452
|
+
async function ensureTreeIndex() {
|
|
453
|
+
const state = getState();
|
|
454
|
+
const cwd = state.cwd;
|
|
455
|
+
if (!cwd) return;
|
|
456
|
+
const key = `${cwd}|ignored=${state.searchIncludeIgnored ? 1 : 0}`;
|
|
457
|
+
if (state.treeIndexKey === key && state.treeIndex !== null) return;
|
|
458
|
+
if (state.treeIndexLoading && state.treeIndexKey === key) return;
|
|
459
|
+
|
|
460
|
+
state.treeIndexLoading = true;
|
|
461
|
+
state.treeIndexCwd = cwd;
|
|
462
|
+
state.treeIndexKey = key;
|
|
463
|
+
state.treeIndexError = null;
|
|
464
|
+
renderList();
|
|
465
|
+
|
|
466
|
+
try {
|
|
467
|
+
const url = `/api/files/list?cwd=${encodeURIComponent(cwd)}`
|
|
468
|
+
+ (state.searchIncludeIgnored ? '&include_ignored=true' : '');
|
|
469
|
+
const response = await fetch(url);
|
|
470
|
+
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
|
471
|
+
const data = await response.json();
|
|
472
|
+
// `files` entries are either relative to cwd or already absolute (from extra_dirs)
|
|
473
|
+
state.treeIndex = (data.files || []).map(f => resolvePath(data.cwd || cwd, f));
|
|
474
|
+
state.treeIndexTruncated = !!data.truncated;
|
|
475
|
+
} catch (err) {
|
|
476
|
+
console.error('Failed to fetch tree index:', err);
|
|
477
|
+
state.treeIndex = [];
|
|
478
|
+
state.treeIndexError = err.message || 'Failed to load file index';
|
|
479
|
+
} finally {
|
|
480
|
+
state.treeIndexLoading = false;
|
|
481
|
+
renderList();
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
function invalidateTreeIndex() {
|
|
486
|
+
const state = getState();
|
|
487
|
+
state.treeIndex = null;
|
|
488
|
+
state.treeIndexCwd = null;
|
|
489
|
+
state.treeIndexKey = null;
|
|
490
|
+
state.treeIndexError = null;
|
|
491
|
+
state.treeIndexTruncated = false;
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
const FLAT_MATCH_LIMIT = 300;
|
|
495
|
+
|
|
496
|
+
// Score a path against the lowercased query. Higher is better.
|
|
497
|
+
// Combines: filename-vs-path bucket, exact/stem/prefix bonuses, and a depth
|
|
498
|
+
// penalty so root-level files outrank equivalents buried deep in the tree.
|
|
499
|
+
// `cwd` lets us measure depth relative to the project root, not the FS root.
|
|
500
|
+
function scoreMatch(path, q, cwd) {
|
|
501
|
+
const lower = path.toLowerCase();
|
|
502
|
+
const name = basename(lower);
|
|
503
|
+
// Stem = filename without extension (e.g. "readme" from "readme.md").
|
|
504
|
+
const dot = name.lastIndexOf('.');
|
|
505
|
+
const stem = dot > 0 ? name.slice(0, dot) : name;
|
|
506
|
+
|
|
507
|
+
let score;
|
|
508
|
+
if (name === q || stem === q) score = 1000; // exact filename or stem
|
|
509
|
+
else if (name.startsWith(q)) score = 500; // filename starts-with
|
|
510
|
+
else if (name.includes(q)) score = 200; // filename contains
|
|
511
|
+
else if (lower.includes(q)) score = 50; // path-only match
|
|
512
|
+
else return -Infinity; // shouldn't happen (already filtered)
|
|
513
|
+
|
|
514
|
+
// Depth penalty — count slashes in the *relative* path (so paths inside
|
|
515
|
+
// the project root are favored over paths from extra_dirs / nested deep).
|
|
516
|
+
// relativeTo hands back an unchanged (absolute) path when it isn't under
|
|
517
|
+
// cwd, and that extra leading segment is exactly the penalty that keeps
|
|
518
|
+
// extra_dirs hits below in-project ones.
|
|
519
|
+
const rel = cwd ? relativeTo(lower, cwd) : lower;
|
|
520
|
+
const depth = splitPath(rel, path).length - 1;
|
|
521
|
+
score -= depth * 10;
|
|
522
|
+
|
|
523
|
+
return score;
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
function filterTreeIndex(query) {
|
|
527
|
+
const state = getState();
|
|
528
|
+
if (!state.treeIndex) return null;
|
|
529
|
+
const q = query.toLowerCase();
|
|
530
|
+
const cwd = (state.cwd || state.currentPath || '').toLowerCase();
|
|
531
|
+
const subdirsOff = !state.searchSubdirs;
|
|
532
|
+
|
|
533
|
+
const matches = [];
|
|
534
|
+
for (const p of state.treeIndex) {
|
|
535
|
+
const lower = p.toLowerCase();
|
|
536
|
+
if (!lower.includes(q)) continue;
|
|
537
|
+
if (subdirsOff) {
|
|
538
|
+
// Only direct children of cwd: one segment below it, no deeper.
|
|
539
|
+
if (!cwd || !isUnder(lower, cwd)) continue;
|
|
540
|
+
if (splitPath(relativeTo(lower, cwd), lower).length !== 1) continue;
|
|
541
|
+
}
|
|
542
|
+
matches.push(p);
|
|
543
|
+
}
|
|
544
|
+
matches.sort((a, b) => {
|
|
545
|
+
const sa = scoreMatch(a, q, cwd);
|
|
546
|
+
const sb = scoreMatch(b, q, cwd);
|
|
547
|
+
if (sa !== sb) return sb - sa; // higher score first
|
|
548
|
+
if (a.length !== b.length) return a.length - b.length; // shorter path wins ties
|
|
549
|
+
return a.localeCompare(b);
|
|
550
|
+
});
|
|
551
|
+
return matches.slice(0, FLAT_MATCH_LIMIT);
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
// ============================================================================
|
|
555
|
+
// Rendering Functions
|
|
556
|
+
// ============================================================================
|
|
557
|
+
|
|
558
|
+
function renderToolbar(container) {
|
|
559
|
+
const state = getState();
|
|
560
|
+
const toolbar = document.createElement('div');
|
|
561
|
+
toolbar.className = 'fe-toolbar';
|
|
562
|
+
|
|
563
|
+
// File-type filter dropdown
|
|
564
|
+
const filterSelect = document.createElement('select');
|
|
565
|
+
filterSelect.className = 'fe-filter';
|
|
566
|
+
filterSelect.innerHTML = `
|
|
567
|
+
<option value="all" ${state.filter === 'all' ? 'selected' : ''}>All Files</option>
|
|
568
|
+
<option value="code" ${state.filter === 'code' ? 'selected' : ''}>Code</option>
|
|
569
|
+
<option value="images" ${state.filter === 'images' ? 'selected' : ''}>Images</option>
|
|
570
|
+
<option value="docs" ${state.filter === 'docs' ? 'selected' : ''}>Docs</option>
|
|
571
|
+
<option value="folders" ${state.filter === 'folders' ? 'selected' : ''}>Folders</option>
|
|
572
|
+
`;
|
|
573
|
+
filterSelect.onchange = () => {
|
|
574
|
+
state.filter = filterSelect.value;
|
|
575
|
+
renderContent();
|
|
576
|
+
};
|
|
577
|
+
toolbar.appendChild(filterSelect);
|
|
578
|
+
|
|
579
|
+
// Actions
|
|
580
|
+
const actions = document.createElement('div');
|
|
581
|
+
actions.className = 'fe-actions';
|
|
582
|
+
|
|
583
|
+
// Grouping: dirs-first / files-first / mixed — cycles on click
|
|
584
|
+
const groupBtn = document.createElement('button');
|
|
585
|
+
groupBtn.className = 'fe-action-btn fe-group-btn';
|
|
586
|
+
const modeLabels = { 'dirs-first': 'Dirs first', 'files-first': 'Files first', 'mixed': 'Mixed' };
|
|
587
|
+
groupBtn.setAttribute('data-tooltip', `Grouping: ${modeLabels[sortPrefs.groupDirs]} (click to cycle)`);
|
|
588
|
+
groupBtn.innerHTML = GROUP_ICONS[sortPrefs.groupDirs];
|
|
589
|
+
groupBtn.onclick = () => {
|
|
590
|
+
const idx = GROUP_MODES.indexOf(sortPrefs.groupDirs);
|
|
591
|
+
const next = GROUP_MODES[(idx + 1) % GROUP_MODES.length];
|
|
592
|
+
setSortPrefs({ groupDirs: next });
|
|
593
|
+
};
|
|
594
|
+
actions.appendChild(groupBtn);
|
|
595
|
+
|
|
596
|
+
// Hidden files toggle
|
|
597
|
+
const hiddenBtn = document.createElement('button');
|
|
598
|
+
hiddenBtn.className = `fe-action-btn ${state.showHidden ? 'active' : ''}`;
|
|
599
|
+
hiddenBtn.setAttribute('data-tooltip', 'Show Hidden Files');
|
|
600
|
+
hiddenBtn.innerHTML = ICONS.eye || '👁';
|
|
601
|
+
hiddenBtn.onclick = () => {
|
|
602
|
+
state.showHidden = !state.showHidden;
|
|
603
|
+
renderContent();
|
|
604
|
+
};
|
|
605
|
+
actions.appendChild(hiddenBtn);
|
|
606
|
+
|
|
607
|
+
// Refresh — in-place so selection/expansion/scroll are preserved, and
|
|
608
|
+
// expanded subtrees also refetch (not just the top level).
|
|
609
|
+
const refreshBtn = document.createElement('button');
|
|
610
|
+
refreshBtn.className = 'fe-action-btn';
|
|
611
|
+
refreshBtn.setAttribute('data-tooltip', 'Refresh');
|
|
612
|
+
refreshBtn.innerHTML = ICONS.refresh || '↻';
|
|
613
|
+
refreshBtn.onclick = () => refreshInPlace({ force: true });
|
|
614
|
+
actions.appendChild(refreshBtn);
|
|
615
|
+
|
|
616
|
+
toolbar.appendChild(actions);
|
|
617
|
+
container.appendChild(toolbar);
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
function renderBreadcrumb(container) {
|
|
621
|
+
const state = getState();
|
|
622
|
+
|
|
623
|
+
const breadcrumb = document.createElement('div');
|
|
624
|
+
breadcrumb.className = 'fe-breadcrumb';
|
|
625
|
+
|
|
626
|
+
if (state.pathEditing) {
|
|
627
|
+
renderPathEditor(breadcrumb);
|
|
628
|
+
} else {
|
|
629
|
+
renderPathSegments(breadcrumb);
|
|
630
|
+
renderEditPathButton(breadcrumb);
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
container.appendChild(breadcrumb);
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
function renderPathSegments(parent) {
|
|
637
|
+
const state = getState();
|
|
638
|
+
const pathContainer = document.createElement('div');
|
|
639
|
+
pathContainer.className = 'fe-path';
|
|
640
|
+
|
|
641
|
+
if (state.currentPath) {
|
|
642
|
+
// Segments hang off the root ('/' or 'C:\'), which is a crumb of its
|
|
643
|
+
// own rather than a name — splitting it into a segment is what turned
|
|
644
|
+
// a Windows drive letter into a directory called "C:".
|
|
645
|
+
const root = pathRoot(state.currentPath);
|
|
646
|
+
const parts = splitPath(state.currentPath.slice(root.length), state.currentPath);
|
|
647
|
+
let accumulated = root ? stripTrailingSep(root) : '';
|
|
648
|
+
|
|
649
|
+
const homeBtn = document.createElement('button');
|
|
650
|
+
homeBtn.className = 'fe-path-segment fe-home';
|
|
651
|
+
homeBtn.innerHTML = ICONS.home || '🏠';
|
|
652
|
+
homeBtn.setAttribute('data-tooltip', state.cwd || '/');
|
|
653
|
+
homeBtn.onclick = () => loadDirectory(state.cwd || '/');
|
|
654
|
+
pathContainer.appendChild(homeBtn);
|
|
655
|
+
|
|
656
|
+
const sepChar = pathSep(state.currentPath);
|
|
657
|
+
parts.forEach(part => {
|
|
658
|
+
accumulated = joinPath(accumulated || root, part);
|
|
659
|
+
const path = accumulated;
|
|
660
|
+
|
|
661
|
+
const sep = document.createElement('span');
|
|
662
|
+
sep.className = 'fe-path-sep';
|
|
663
|
+
sep.textContent = sepChar;
|
|
664
|
+
pathContainer.appendChild(sep);
|
|
665
|
+
|
|
666
|
+
const segment = document.createElement('button');
|
|
667
|
+
segment.className = 'fe-path-segment';
|
|
668
|
+
segment.textContent = part;
|
|
669
|
+
segment.onclick = () => loadDirectory(path);
|
|
670
|
+
pathContainer.appendChild(segment);
|
|
671
|
+
});
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
parent.appendChild(pathContainer);
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
function renderEditPathButton(parent) {
|
|
678
|
+
const btn = document.createElement('button');
|
|
679
|
+
btn.className = 'fe-path-edit-btn';
|
|
680
|
+
btn.setAttribute('data-tooltip', 'Edit path');
|
|
681
|
+
btn.innerHTML = ICONS.edit || '✎';
|
|
682
|
+
btn.onclick = startPathEdit;
|
|
683
|
+
parent.appendChild(btn);
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
function renderPathEditor(parent) {
|
|
687
|
+
const state = getState();
|
|
688
|
+
const input = document.createElement('input');
|
|
689
|
+
input.type = 'text';
|
|
690
|
+
input.className = 'fe-path-input';
|
|
691
|
+
input.value = state.currentPath || state.cwd || '/';
|
|
692
|
+
input.spellcheck = false;
|
|
693
|
+
input.autocomplete = 'off';
|
|
694
|
+
input.setAttribute('aria-label', 'Type a path and press Enter');
|
|
695
|
+
|
|
696
|
+
input.addEventListener('keydown', (e) => {
|
|
697
|
+
if (e.key === 'Enter') {
|
|
698
|
+
e.preventDefault();
|
|
699
|
+
e.stopPropagation();
|
|
700
|
+
commitPath(input.value);
|
|
701
|
+
} else if (e.key === 'Escape') {
|
|
702
|
+
// Stop here so the widget-level Escape doesn't also fire (which
|
|
703
|
+
// would try to close the filter bar or the widget itself).
|
|
704
|
+
e.preventDefault();
|
|
705
|
+
e.stopPropagation();
|
|
706
|
+
cancelPathEdit();
|
|
707
|
+
}
|
|
708
|
+
});
|
|
709
|
+
|
|
710
|
+
input.addEventListener('blur', () => {
|
|
711
|
+
// If we're still editing, the user clicked away → cancel.
|
|
712
|
+
// commit / cancel flip pathEditing off first so this is a no-op in
|
|
713
|
+
// those paths.
|
|
714
|
+
if (getState().pathEditing) cancelPathEdit();
|
|
715
|
+
});
|
|
716
|
+
|
|
717
|
+
parent.appendChild(input);
|
|
718
|
+
|
|
719
|
+
// Focus after it's in the DOM; select-all so a quick retype replaces it.
|
|
720
|
+
setTimeout(() => {
|
|
721
|
+
input.focus();
|
|
722
|
+
input.select();
|
|
723
|
+
}, 0);
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
function startPathEdit() {
|
|
727
|
+
const state = getState();
|
|
728
|
+
state.pathEditing = true;
|
|
729
|
+
renderContent();
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
function cancelPathEdit() {
|
|
733
|
+
const state = getState();
|
|
734
|
+
state.pathEditing = false;
|
|
735
|
+
renderContent();
|
|
736
|
+
state.currentContainer?.focus({ preventScroll: true });
|
|
737
|
+
}
|
|
738
|
+
|
|
739
|
+
/**
|
|
740
|
+
* The server-reported HOME, or a stand-in built from the session cwd while
|
|
741
|
+
* the "connected" WebSocket message hasn't landed yet. The stand-in is the
|
|
742
|
+
* first two segments below the root — /home/<user>, C:\Users\<user> — which
|
|
743
|
+
* is why it counts segments off `pathRoot` instead of slicing the string:
|
|
744
|
+
* a drive letter is a root, not a segment.
|
|
745
|
+
*/
|
|
746
|
+
function homeGuess() {
|
|
747
|
+
const state = getState();
|
|
748
|
+
if (CONFIG.HOME && CONFIG.HOME !== '/home') return CONFIG.HOME;
|
|
749
|
+
if (!state.cwd) return '/';
|
|
750
|
+
const root = pathRoot(state.cwd);
|
|
751
|
+
const parts = splitPath(state.cwd.slice(root.length), state.cwd).slice(0, 2);
|
|
752
|
+
return parts.reduce((acc, part) => joinPath(acc, part), stripTrailingSep(root) || root || '');
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
function commitPath(raw) {
|
|
756
|
+
const state = getState();
|
|
757
|
+
state.pathEditing = false;
|
|
758
|
+
const trimmed = (raw || '').trim();
|
|
759
|
+
if (!trimmed) { renderContent(); return; }
|
|
760
|
+
|
|
761
|
+
let resolved = trimmed;
|
|
762
|
+
if (resolved.startsWith('~')) {
|
|
763
|
+
// "~" or "~/path" — expand to the real server-reported HOME that
|
|
764
|
+
// arrives with the "connected" WebSocket message. Falls back to the
|
|
765
|
+
// first two segments of the session cwd (/home/<user>) if HOME
|
|
766
|
+
// hasn't been populated yet.
|
|
767
|
+
resolved = homeGuess() + resolved.slice(1);
|
|
768
|
+
}
|
|
769
|
+
// Relative input hangs off the current directory; an absolute path (or
|
|
770
|
+
// the ~-expansion above) just passes through. Either way resolvePath
|
|
771
|
+
// re-emits with the server's separator and drops any trailing one, so a
|
|
772
|
+
// typed '/' still lands on a Windows server.
|
|
773
|
+
resolved = resolvePath(state.currentPath || state.cwd || pathRoot(state.cwd) || '/', resolved);
|
|
774
|
+
|
|
775
|
+
loadDirectory(resolved);
|
|
776
|
+
}
|
|
777
|
+
|
|
778
|
+
function sortFiles(files) {
|
|
779
|
+
const sorted = [...files];
|
|
780
|
+
sorted.sort((a, b) => {
|
|
781
|
+
// Group dirs and files before applying field sort
|
|
782
|
+
if (sortPrefs.groupDirs === 'dirs-first') {
|
|
783
|
+
if (a.is_dir && !b.is_dir) return -1;
|
|
784
|
+
if (!a.is_dir && b.is_dir) return 1;
|
|
785
|
+
} else if (sortPrefs.groupDirs === 'files-first') {
|
|
786
|
+
if (a.is_dir && !b.is_dir) return 1;
|
|
787
|
+
if (!a.is_dir && b.is_dir) return -1;
|
|
788
|
+
}
|
|
789
|
+
// else 'mixed' — no grouping, fall through
|
|
790
|
+
|
|
791
|
+
let cmp = 0;
|
|
792
|
+
switch (sortPrefs.sortBy) {
|
|
793
|
+
case 'modified':
|
|
794
|
+
cmp = (a.mtime || 0) - (b.mtime || 0);
|
|
795
|
+
break;
|
|
796
|
+
case 'size':
|
|
797
|
+
cmp = (a.size || 0) - (b.size || 0);
|
|
798
|
+
break;
|
|
799
|
+
case 'kind':
|
|
800
|
+
// Dirs bucket ahead of every file kind, and that has to be
|
|
801
|
+
// its own comparison rather than a magic prefix on the sort
|
|
802
|
+
// key: localeCompare ignores the characters you'd reach for
|
|
803
|
+
// (a leading NUL collates to literally nothing, so '\0folder'
|
|
804
|
+
// and 'folder' compare equal).
|
|
805
|
+
cmp = (a.is_dir ? 0 : 1) - (b.is_dir ? 0 : 1) ||
|
|
806
|
+
getKindSortKey(a).localeCompare(getKindSortKey(b));
|
|
807
|
+
break;
|
|
808
|
+
default:
|
|
809
|
+
cmp = a.name.localeCompare(b.name);
|
|
810
|
+
}
|
|
811
|
+
if (cmp === 0) cmp = a.name.localeCompare(b.name);
|
|
812
|
+
return sortPrefs.sortAsc ? cmp : -cmp;
|
|
813
|
+
});
|
|
814
|
+
return sorted;
|
|
815
|
+
}
|
|
816
|
+
|
|
817
|
+
function filterFiles(files) {
|
|
818
|
+
const state = getState();
|
|
819
|
+
return files
|
|
820
|
+
.filter(f => matchesFilter(f, state.filter))
|
|
821
|
+
.filter(f => state.showHidden || !f.name.startsWith('.'));
|
|
822
|
+
}
|
|
823
|
+
|
|
824
|
+
// Inline filter: does this branch contain a match for the query?
|
|
825
|
+
// Recurses through expanded children (we can only filter what's loaded).
|
|
826
|
+
function branchMatches(file, absPath, query) {
|
|
827
|
+
if (!query) return true;
|
|
828
|
+
const q = query.toLowerCase();
|
|
829
|
+
if (file.name.toLowerCase().includes(q)) return true;
|
|
830
|
+
if (!file.is_dir) return false;
|
|
831
|
+
const state = getState();
|
|
832
|
+
if (!state.expanded.has(absPath)) return false;
|
|
833
|
+
const children = state.childrenCache.get(absPath) || [];
|
|
834
|
+
for (const c of children) {
|
|
835
|
+
const childPath = c.path || joinPath(absPath, c.name);
|
|
836
|
+
if (branchMatches(c, childPath, query)) return true;
|
|
837
|
+
}
|
|
838
|
+
return false;
|
|
839
|
+
}
|
|
840
|
+
|
|
841
|
+
function renderFilterBar(container) {
|
|
842
|
+
const state = getState();
|
|
843
|
+
if (!state.filterBarOpen) return;
|
|
844
|
+
const bar = document.createElement('div');
|
|
845
|
+
bar.className = 'fe-filter-bar';
|
|
846
|
+
const subdirsActive = state.searchSubdirs;
|
|
847
|
+
const ignoredActive = state.searchIncludeIgnored;
|
|
848
|
+
const subdirsTip = subdirsActive
|
|
849
|
+
? (S.file_explorer?.search_subdirs_on || 'Searching subfolders (click to limit to current folder)')
|
|
850
|
+
: (S.file_explorer?.search_subdirs_off || 'Limited to current folder (click to search subfolders)');
|
|
851
|
+
const ignoredTip = ignoredActive
|
|
852
|
+
? (S.file_explorer?.include_ignored_on || 'Including .gitignore\'d files (click to respect .gitignore)')
|
|
853
|
+
: (S.file_explorer?.include_ignored_off || 'Respecting .gitignore (click to include ignored files)');
|
|
854
|
+
bar.innerHTML = `
|
|
855
|
+
<span class="fe-filter-icon">${ICONS.search || '🔍'}</span>
|
|
856
|
+
<input type="text" class="fe-filter-input" placeholder="Filter tree…" />
|
|
857
|
+
<button class="fe-filter-toggle ${subdirsActive ? 'active' : ''}" data-toggle="subdirs" data-tooltip="${subdirsTip}">
|
|
858
|
+
${ICONS.folderTree || '🌳'}
|
|
859
|
+
</button>
|
|
860
|
+
<button class="fe-filter-toggle ${ignoredActive ? 'active' : ''}" data-toggle="ignored" data-tooltip="${ignoredTip}">
|
|
861
|
+
${ignoredActive ? (ICONS.eye || '👁') : (ICONS.eyeOff || '🚫')}
|
|
862
|
+
</button>
|
|
863
|
+
<button class="fe-filter-clear" data-tooltip="Clear filter (Esc)">✕</button>
|
|
864
|
+
`;
|
|
865
|
+
const input = bar.querySelector('.fe-filter-input');
|
|
866
|
+
input.value = state.filterQuery;
|
|
867
|
+
input.addEventListener('input', (e) => {
|
|
868
|
+
state.filterQuery = e.target.value;
|
|
869
|
+
// Re-render just the list (keeps the filter input focused and caret stable)
|
|
870
|
+
renderList();
|
|
871
|
+
});
|
|
872
|
+
bar.querySelector('[data-toggle="subdirs"]').addEventListener('click', (e) => {
|
|
873
|
+
e.preventDefault();
|
|
874
|
+
state.searchSubdirs = !state.searchSubdirs;
|
|
875
|
+
renderContent();
|
|
876
|
+
// Refocus the input so typing continues without an extra click
|
|
877
|
+
state.currentContainer?.querySelector('.fe-filter-input')?.focus();
|
|
878
|
+
});
|
|
879
|
+
bar.querySelector('[data-toggle="ignored"]').addEventListener('click', (e) => {
|
|
880
|
+
e.preventDefault();
|
|
881
|
+
state.searchIncludeIgnored = !state.searchIncludeIgnored;
|
|
882
|
+
// Index params changed — drop the cache so the next ensureTreeIndex refetches
|
|
883
|
+
invalidateTreeIndex();
|
|
884
|
+
ensureTreeIndex();
|
|
885
|
+
renderContent();
|
|
886
|
+
state.currentContainer?.querySelector('.fe-filter-input')?.focus();
|
|
887
|
+
});
|
|
888
|
+
input.addEventListener('keydown', (e) => {
|
|
889
|
+
if (e.key === 'Escape') {
|
|
890
|
+
e.preventDefault();
|
|
891
|
+
e.stopPropagation();
|
|
892
|
+
closeFilterBar();
|
|
893
|
+
return;
|
|
894
|
+
}
|
|
895
|
+
// ArrowDown from the filter input jumps to the first visible result.
|
|
896
|
+
// ArrowUp jumps to the last. Enter acts on the first match.
|
|
897
|
+
if (e.key === 'ArrowDown' || e.key === 'ArrowUp' || e.key === 'Enter') {
|
|
898
|
+
const items = getVisibleItems();
|
|
899
|
+
if (items.length === 0) return;
|
|
900
|
+
e.preventDefault();
|
|
901
|
+
const state = getState();
|
|
902
|
+
state.currentContainer?.focus({ preventScroll: true });
|
|
903
|
+
const target = e.key === 'ArrowUp' ? items[items.length - 1] : items[0];
|
|
904
|
+
setSelection(target.dataset.path);
|
|
905
|
+
if (e.key === 'Enter') activateSelection();
|
|
906
|
+
}
|
|
907
|
+
});
|
|
908
|
+
bar.querySelector('.fe-filter-clear').onclick = () => closeFilterBar();
|
|
909
|
+
container.appendChild(bar);
|
|
910
|
+
}
|
|
911
|
+
|
|
912
|
+
function closeFilterBar() {
|
|
913
|
+
const state = getState();
|
|
914
|
+
state.filterBarOpen = false;
|
|
915
|
+
state.filterQuery = '';
|
|
916
|
+
renderContent();
|
|
917
|
+
state.currentContainer?.focus({ preventScroll: true });
|
|
918
|
+
}
|
|
919
|
+
|
|
920
|
+
function openFilterBar(initialChar = '') {
|
|
921
|
+
const state = getState();
|
|
922
|
+
state.filterBarOpen = true;
|
|
923
|
+
if (initialChar) state.filterQuery = initialChar;
|
|
924
|
+
// Kick off the recursive index fetch early so the second keystroke has
|
|
925
|
+
// results to match against — /api/files/list is cached server-side per-cwd.
|
|
926
|
+
ensureTreeIndex();
|
|
927
|
+
renderContent();
|
|
928
|
+
const input = state.currentContainer?.querySelector('.fe-filter-input');
|
|
929
|
+
if (input) {
|
|
930
|
+
input.focus();
|
|
931
|
+
input.setSelectionRange(input.value.length, input.value.length);
|
|
932
|
+
}
|
|
933
|
+
}
|
|
934
|
+
|
|
935
|
+
function renderHeaderRow(list) {
|
|
936
|
+
const header = document.createElement('div');
|
|
937
|
+
header.className = 'fe-header-row';
|
|
938
|
+
const cols = [
|
|
939
|
+
{ field: 'name', label: 'Name', cls: 'fe-header-name' },
|
|
940
|
+
{ field: 'kind', label: 'Kind', cls: 'fe-header-kind' },
|
|
941
|
+
{ field: 'modified', label: 'Modified', cls: 'fe-header-modified' },
|
|
942
|
+
{ field: 'size', label: 'Size', cls: 'fe-header-size' },
|
|
943
|
+
];
|
|
944
|
+
cols.forEach(c => {
|
|
945
|
+
const btn = document.createElement('button');
|
|
946
|
+
const active = sortPrefs.sortBy === c.field;
|
|
947
|
+
btn.className = `fe-header-col ${c.cls} ${active ? 'active' : ''}`;
|
|
948
|
+
const indicator = active
|
|
949
|
+
? (sortPrefs.sortAsc ? '<span class="fe-header-caret">▲</span>' : '<span class="fe-header-caret">▼</span>')
|
|
950
|
+
: '';
|
|
951
|
+
btn.innerHTML = `<span class="fe-header-label">${c.label}</span>${indicator}`;
|
|
952
|
+
btn.onclick = () => {
|
|
953
|
+
if (sortPrefs.sortBy === c.field) {
|
|
954
|
+
setSortPrefs({ sortAsc: !sortPrefs.sortAsc });
|
|
955
|
+
} else {
|
|
956
|
+
setSortPrefs({ sortBy: c.field });
|
|
957
|
+
}
|
|
958
|
+
};
|
|
959
|
+
header.appendChild(btn);
|
|
960
|
+
});
|
|
961
|
+
list.appendChild(header);
|
|
962
|
+
}
|
|
963
|
+
|
|
964
|
+
function renderTreeNode(list, file, depth) {
|
|
965
|
+
const state = getState();
|
|
966
|
+
const absPath = file.path || joinPath(state.currentPath, file.name);
|
|
967
|
+
const query = state.filterBarOpen ? state.filterQuery : '';
|
|
968
|
+
if (query && !branchMatches(file, absPath, query)) return;
|
|
969
|
+
|
|
970
|
+
const item = renderFileItem(file, depth);
|
|
971
|
+
list.appendChild(item);
|
|
972
|
+
|
|
973
|
+
if (!file.is_dir) return;
|
|
974
|
+
if (!state.expanded.has(absPath)) return;
|
|
975
|
+
|
|
976
|
+
const children = state.childrenCache.get(absPath);
|
|
977
|
+
if (children === undefined) {
|
|
978
|
+
// Fetch in progress — placeholder
|
|
979
|
+
if (state.loadingChildren.has(absPath)) {
|
|
980
|
+
const loader = document.createElement('div');
|
|
981
|
+
loader.className = 'fe-item fe-tree-loading';
|
|
982
|
+
loader.style.paddingLeft = `${16 + (depth + 1) * 18}px`;
|
|
983
|
+
loader.textContent = 'Loading…';
|
|
984
|
+
list.appendChild(loader);
|
|
985
|
+
}
|
|
986
|
+
return;
|
|
987
|
+
}
|
|
988
|
+
// Subtree fetch errored — show a concise inline notice instead of an
|
|
989
|
+
// empty dir, so the user understands why it won't open.
|
|
990
|
+
const childErr = state.childrenError.get(absPath);
|
|
991
|
+
if (childErr) {
|
|
992
|
+
const desc = describeDirError(childErr);
|
|
993
|
+
const row = document.createElement('div');
|
|
994
|
+
row.className = 'fe-item fe-tree-error';
|
|
995
|
+
row.style.paddingLeft = `${16 + (depth + 1) * 18}px`;
|
|
996
|
+
row.textContent = desc?.title || 'Can\'t open';
|
|
997
|
+
row.setAttribute('data-tooltip', desc?.body || childErr.detail || '');
|
|
998
|
+
list.appendChild(row);
|
|
999
|
+
return;
|
|
1000
|
+
}
|
|
1001
|
+
const rendered = sortFiles(filterFiles(children));
|
|
1002
|
+
const anyMatching = query
|
|
1003
|
+
? rendered.filter(c => branchMatches(c, c.path || joinPath(absPath, c.name), query))
|
|
1004
|
+
: rendered;
|
|
1005
|
+
if (anyMatching.length === 0 && !query) {
|
|
1006
|
+
const empty = document.createElement('div');
|
|
1007
|
+
empty.className = 'fe-item fe-tree-empty';
|
|
1008
|
+
empty.style.paddingLeft = `${16 + (depth + 1) * 18}px`;
|
|
1009
|
+
empty.textContent = 'Empty';
|
|
1010
|
+
list.appendChild(empty);
|
|
1011
|
+
}
|
|
1012
|
+
rendered.forEach(c => renderTreeNode(list, c, depth + 1));
|
|
1013
|
+
}
|
|
1014
|
+
|
|
1015
|
+
function renderFileList(container) {
|
|
1016
|
+
const state = getState();
|
|
1017
|
+
const query = state.filterBarOpen ? state.filterQuery : '';
|
|
1018
|
+
|
|
1019
|
+
// Recursive-search mode: ≥2 chars flips from in-memory tree filter to the
|
|
1020
|
+
// flat match list backed by /api/files/list. Kicks off a fetch on demand;
|
|
1021
|
+
// while loading, show a loader. Column headers are suppressed because the
|
|
1022
|
+
// results are flat (not tree-aligned with sortable columns).
|
|
1023
|
+
if (query && query.length >= 2) {
|
|
1024
|
+
ensureTreeIndex();
|
|
1025
|
+
const matches = filterTreeIndex(query);
|
|
1026
|
+
renderFlatMatches(container, matches);
|
|
1027
|
+
return;
|
|
1028
|
+
}
|
|
1029
|
+
|
|
1030
|
+
// Column headers: outside the scroll area so they never scroll away
|
|
1031
|
+
renderHeaderRow(container);
|
|
1032
|
+
|
|
1033
|
+
const list = document.createElement('div');
|
|
1034
|
+
list.className = 'fe-list';
|
|
1035
|
+
|
|
1036
|
+
// Top-level error block: listing itself couldn't be fetched (403/404/etc).
|
|
1037
|
+
// Rendered inside .fe-list so it gets the same layout/scroll container.
|
|
1038
|
+
if (state.errorInfo) {
|
|
1039
|
+
renderErrorBlock(list, state.errorInfo);
|
|
1040
|
+
container.appendChild(list);
|
|
1041
|
+
return;
|
|
1042
|
+
}
|
|
1043
|
+
|
|
1044
|
+
const files = sortFiles(filterFiles(state.files));
|
|
1045
|
+
|
|
1046
|
+
// Parent directory (always visible, never filtered)
|
|
1047
|
+
if (state.currentPath && state.currentPath !== '/' && state.currentPath !== state.cwd) {
|
|
1048
|
+
const parentPath = parentOf(state.currentPath) || pathRoot(state.currentPath) || '/';
|
|
1049
|
+
const parent = document.createElement('div');
|
|
1050
|
+
parent.className = 'fe-item fe-parent';
|
|
1051
|
+
parent.innerHTML = `
|
|
1052
|
+
<span class="fe-tree-spacer"></span>
|
|
1053
|
+
<span class="fe-icon">${ICONS.folderUp || '📁'}</span>
|
|
1054
|
+
<span class="fe-name">..</span>
|
|
1055
|
+
`;
|
|
1056
|
+
parent.onclick = () => loadDirectory(parentPath);
|
|
1057
|
+
list.appendChild(parent);
|
|
1058
|
+
}
|
|
1059
|
+
|
|
1060
|
+
// Files (possibly with expanded subtrees; renderTreeNode applies filter)
|
|
1061
|
+
files.forEach(file => renderTreeNode(list, file, 0));
|
|
1062
|
+
|
|
1063
|
+
// Empty states
|
|
1064
|
+
const hasItems = list.querySelector('.fe-item:not(.fe-parent)');
|
|
1065
|
+
if (!hasItems) {
|
|
1066
|
+
const empty = document.createElement('div');
|
|
1067
|
+
empty.className = 'fe-empty';
|
|
1068
|
+
if (query) {
|
|
1069
|
+
empty.innerHTML = `
|
|
1070
|
+
<span class="fe-empty-icon">${ICONS.search || '🔍'}</span>
|
|
1071
|
+
<span class="fe-empty-text">No matches for "${escapeHtml(query)}"</span>
|
|
1072
|
+
`;
|
|
1073
|
+
} else {
|
|
1074
|
+
empty.innerHTML = `
|
|
1075
|
+
<span class="fe-empty-icon">${ICONS.folder || '📂'}</span>
|
|
1076
|
+
<span class="fe-empty-text">${state.filter !== 'all' ? 'No matching files' : 'Empty directory'}</span>
|
|
1077
|
+
`;
|
|
1078
|
+
}
|
|
1079
|
+
list.appendChild(empty);
|
|
1080
|
+
}
|
|
1081
|
+
|
|
1082
|
+
container.appendChild(list);
|
|
1083
|
+
}
|
|
1084
|
+
|
|
1085
|
+
function renderErrorBlock(list, errInfo) {
|
|
1086
|
+
const state = getState();
|
|
1087
|
+
const desc = describeDirError(errInfo);
|
|
1088
|
+
const block = document.createElement('div');
|
|
1089
|
+
block.className = 'fe-error';
|
|
1090
|
+
|
|
1091
|
+
const iconSvg = ICONS[desc.icon] || ICONS.alert || '⚠';
|
|
1092
|
+
const pathText = errInfo.path ? escapeHtml(errInfo.path) : '';
|
|
1093
|
+
|
|
1094
|
+
block.innerHTML = `
|
|
1095
|
+
<div class="fe-error-icon">${iconSvg}</div>
|
|
1096
|
+
<div class="fe-error-title">${escapeHtml(desc.title)}</div>
|
|
1097
|
+
<div class="fe-error-body">${escapeHtml(desc.body)}</div>
|
|
1098
|
+
${pathText ? `<div class="fe-error-path">${pathText}</div>` : ''}
|
|
1099
|
+
<div class="fe-error-actions"></div>
|
|
1100
|
+
`;
|
|
1101
|
+
|
|
1102
|
+
const actions = block.querySelector('.fe-error-actions');
|
|
1103
|
+
const isFilePath = errInfo.status === 400 && errInfo.detail === 'Not a directory';
|
|
1104
|
+
|
|
1105
|
+
if (isFilePath && errInfo.path) {
|
|
1106
|
+
const previewBtn = document.createElement('button');
|
|
1107
|
+
previewBtn.className = 'fe-error-btn fe-error-btn-primary';
|
|
1108
|
+
previewBtn.textContent = 'Preview file';
|
|
1109
|
+
previewBtn.onclick = () => {
|
|
1110
|
+
const parent = parentOf(errInfo.path) || pathRoot(errInfo.path) || '/';
|
|
1111
|
+
loadDirectory(parent);
|
|
1112
|
+
window.app?.previewFile(errInfo.path, { imageGallery: 'dir' });
|
|
1113
|
+
};
|
|
1114
|
+
actions.appendChild(previewBtn);
|
|
1115
|
+
}
|
|
1116
|
+
|
|
1117
|
+
const parentPath = (errInfo.path && parentOf(errInfo.path)) || '';
|
|
1118
|
+
if (parentPath && parentPath !== errInfo.path && parentPath !== '') {
|
|
1119
|
+
const parentBtn = document.createElement('button');
|
|
1120
|
+
parentBtn.className = 'fe-error-btn';
|
|
1121
|
+
parentBtn.textContent = 'Go to parent';
|
|
1122
|
+
parentBtn.onclick = () => loadDirectory(parentPath || '/');
|
|
1123
|
+
actions.appendChild(parentBtn);
|
|
1124
|
+
}
|
|
1125
|
+
|
|
1126
|
+
if (state.cwd && errInfo.path !== state.cwd) {
|
|
1127
|
+
const homeBtn = document.createElement('button');
|
|
1128
|
+
homeBtn.className = `fe-error-btn ${isFilePath ? '' : 'fe-error-btn-primary'}`;
|
|
1129
|
+
homeBtn.textContent = 'Go to project root';
|
|
1130
|
+
homeBtn.onclick = () => loadDirectory(state.cwd);
|
|
1131
|
+
actions.appendChild(homeBtn);
|
|
1132
|
+
}
|
|
1133
|
+
|
|
1134
|
+
const retryBtn = document.createElement('button');
|
|
1135
|
+
retryBtn.className = 'fe-error-btn';
|
|
1136
|
+
retryBtn.textContent = 'Retry';
|
|
1137
|
+
retryBtn.onclick = () => loadDirectory(errInfo.path || state.currentPath);
|
|
1138
|
+
actions.appendChild(retryBtn);
|
|
1139
|
+
|
|
1140
|
+
list.appendChild(block);
|
|
1141
|
+
}
|
|
1142
|
+
|
|
1143
|
+
function renderFileItem(file, depth = 0) {
|
|
1144
|
+
const state = getState();
|
|
1145
|
+
const item = document.createElement('div');
|
|
1146
|
+
item.className = `fe-item ${file.is_dir ? 'fe-dir' : ''}`;
|
|
1147
|
+
item.dataset.path = file.path || joinPath(state.currentPath, file.name);
|
|
1148
|
+
item.dataset.depth = depth;
|
|
1149
|
+
|
|
1150
|
+
const fileType = file.is_dir ? { icon: 'folder', color: 'var(--accent-yellow)' } : getFileType(file.name);
|
|
1151
|
+
const sizeText = file.is_dir ? '' : (file.size !== undefined ? formatFileSize(file.size) : '');
|
|
1152
|
+
const modText = formatDate(file.mtime);
|
|
1153
|
+
const kindText = getKindLabel(file);
|
|
1154
|
+
const absPath = item.dataset.path;
|
|
1155
|
+
const gitStatus = file.is_dir ? dirHasGitChanges(absPath) : state.gitStatus.get(absPath);
|
|
1156
|
+
const gitGlyph = gitStatus ? (GIT_STATUS_ICONS[gitStatus] || gitStatus) : '';
|
|
1157
|
+
const gitBadge = gitStatus
|
|
1158
|
+
? `<span class="fe-git fe-git-${gitStatus}" data-tooltip="${GIT_STATUS_LABELS[gitStatus] || gitStatus}">${gitGlyph}</span>`
|
|
1159
|
+
: '';
|
|
1160
|
+
|
|
1161
|
+
if (gitStatus) {
|
|
1162
|
+
item.classList.add('fe-has-git-change');
|
|
1163
|
+
item.classList.add(`fe-git-status-${gitStatus}`);
|
|
1164
|
+
}
|
|
1165
|
+
if (state.selectedPath === absPath) item.classList.add('selected');
|
|
1166
|
+
|
|
1167
|
+
// Tree indent — reserve one slot per depth level, plus chevron for dirs / spacer for files
|
|
1168
|
+
const indentPx = depth * 18;
|
|
1169
|
+
let chevron = '<span class="fe-tree-spacer"></span>';
|
|
1170
|
+
if (file.is_dir) {
|
|
1171
|
+
const isExpanded = state.expanded.has(absPath);
|
|
1172
|
+
chevron = `<button class="fe-tree-chevron ${isExpanded ? 'expanded' : ''}" data-tooltip="${isExpanded ? 'Collapse' : 'Expand'}">
|
|
1173
|
+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="9 6 15 12 9 18"/></svg>
|
|
1174
|
+
</button>`;
|
|
1175
|
+
}
|
|
1176
|
+
|
|
1177
|
+
item.innerHTML = `
|
|
1178
|
+
<span class="fe-tree-indent" style="width:${indentPx}px"></span>
|
|
1179
|
+
${chevron}
|
|
1180
|
+
<span class="fe-icon" style="color: ${fileType.color}">${ICONS[fileType.icon] || '📄'}</span>
|
|
1181
|
+
<span class="fe-name">${escapeHtml(file.name)}</span>
|
|
1182
|
+
${gitBadge}
|
|
1183
|
+
<span class="fe-kind">${escapeHtml(kindText)}</span>
|
|
1184
|
+
<span class="fe-modified">${modText}</span>
|
|
1185
|
+
<span class="fe-size">${sizeText}</span>
|
|
1186
|
+
`;
|
|
1187
|
+
|
|
1188
|
+
// Chevron click → toggle expand (don't navigate)
|
|
1189
|
+
const chevronEl = item.querySelector('.fe-tree-chevron');
|
|
1190
|
+
if (chevronEl) {
|
|
1191
|
+
chevronEl.onclick = (e) => {
|
|
1192
|
+
e.stopPropagation();
|
|
1193
|
+
toggleExpanded(absPath);
|
|
1194
|
+
};
|
|
1195
|
+
}
|
|
1196
|
+
|
|
1197
|
+
// Row click: dir toggles expansion (like the chevron), file previews.
|
|
1198
|
+
// Double-click on a dir enters it (navigates in). Context menu has "Enter".
|
|
1199
|
+
item.onclick = (e) => {
|
|
1200
|
+
if (chevronEl && chevronEl.contains(e.target)) return; // chevron handled above
|
|
1201
|
+
state.selectedPath = absPath;
|
|
1202
|
+
if (file.is_dir) {
|
|
1203
|
+
toggleExpanded(absPath); // renders internally
|
|
1204
|
+
return;
|
|
1205
|
+
}
|
|
1206
|
+
window.app?.previewFile(absPath, { imageGallery: 'dir' });
|
|
1207
|
+
renderContent();
|
|
1208
|
+
};
|
|
1209
|
+
|
|
1210
|
+
item.ondblclick = () => {
|
|
1211
|
+
if (file.is_dir) {
|
|
1212
|
+
loadDirectory(absPath);
|
|
1213
|
+
} else {
|
|
1214
|
+
window.app?.openFileInEditor(absPath, state.cwd);
|
|
1215
|
+
}
|
|
1216
|
+
};
|
|
1217
|
+
|
|
1218
|
+
item.oncontextmenu = (e) => {
|
|
1219
|
+
e.preventDefault();
|
|
1220
|
+
showContextMenu(e, file, absPath);
|
|
1221
|
+
};
|
|
1222
|
+
|
|
1223
|
+
// Long press for touch
|
|
1224
|
+
let pressTimer;
|
|
1225
|
+
item.ontouchstart = () => {
|
|
1226
|
+
pressTimer = setTimeout(() => {
|
|
1227
|
+
showContextMenu({ clientX: 100, clientY: item.getBoundingClientRect().top }, file, absPath);
|
|
1228
|
+
}, 500);
|
|
1229
|
+
};
|
|
1230
|
+
item.ontouchend = () => clearTimeout(pressTimer);
|
|
1231
|
+
item.ontouchmove = () => clearTimeout(pressTimer);
|
|
1232
|
+
|
|
1233
|
+
return item;
|
|
1234
|
+
}
|
|
1235
|
+
|
|
1236
|
+
async function toggleExpanded(absPath) {
|
|
1237
|
+
const state = getState();
|
|
1238
|
+
if (state.expanded.has(absPath)) {
|
|
1239
|
+
state.expanded.delete(absPath);
|
|
1240
|
+
renderContent();
|
|
1241
|
+
return;
|
|
1242
|
+
}
|
|
1243
|
+
state.expanded.add(absPath);
|
|
1244
|
+
state.childrenError.delete(absPath);
|
|
1245
|
+
if (!state.childrenCache.has(absPath)) {
|
|
1246
|
+
state.loadingChildren.add(absPath);
|
|
1247
|
+
renderContent();
|
|
1248
|
+
try {
|
|
1249
|
+
const children = await fetchDirectory(absPath);
|
|
1250
|
+
state.childrenCache.set(absPath, children);
|
|
1251
|
+
} catch (err) {
|
|
1252
|
+
state.childrenCache.set(absPath, []);
|
|
1253
|
+
state.childrenError.set(absPath, { status: err.status, detail: err.detail });
|
|
1254
|
+
} finally {
|
|
1255
|
+
state.loadingChildren.delete(absPath);
|
|
1256
|
+
}
|
|
1257
|
+
}
|
|
1258
|
+
renderContent();
|
|
1259
|
+
}
|
|
1260
|
+
|
|
1261
|
+
// Flat results list rendered when the inline filter triggers a recursive
|
|
1262
|
+
// search. Replaces the tree for the duration of the active query.
|
|
1263
|
+
function renderFlatMatches(container, matches) {
|
|
1264
|
+
const state = getState();
|
|
1265
|
+
const list = document.createElement('div');
|
|
1266
|
+
list.className = 'fe-list fe-flat-matches';
|
|
1267
|
+
|
|
1268
|
+
if (state.treeIndexLoading && matches === null) {
|
|
1269
|
+
list.innerHTML = `<div class="fe-loading">Scanning subtree…</div>`;
|
|
1270
|
+
container.appendChild(list);
|
|
1271
|
+
return;
|
|
1272
|
+
}
|
|
1273
|
+
|
|
1274
|
+
if (state.treeIndexError) {
|
|
1275
|
+
list.innerHTML = `<div class="fe-empty">Couldn't scan subtree: ${escapeHtml(state.treeIndexError)}</div>`;
|
|
1276
|
+
container.appendChild(list);
|
|
1277
|
+
return;
|
|
1278
|
+
}
|
|
1279
|
+
|
|
1280
|
+
const cwd = state.cwd || state.currentPath || '';
|
|
1281
|
+
const query = state.filterQuery;
|
|
1282
|
+
|
|
1283
|
+
const visible = (matches || []).filter(p => {
|
|
1284
|
+
const name = basename(p);
|
|
1285
|
+
if (!state.showHidden && name.startsWith('.')) return false;
|
|
1286
|
+
return matchesFilter({ name, is_dir: false }, state.filter);
|
|
1287
|
+
});
|
|
1288
|
+
|
|
1289
|
+
if (visible.length === 0) {
|
|
1290
|
+
list.innerHTML = `
|
|
1291
|
+
<div class="fe-empty">
|
|
1292
|
+
<span class="fe-empty-icon">${ICONS.search || '🔍'}</span>
|
|
1293
|
+
<span class="fe-empty-text">No matches for "${escapeHtml(query)}"</span>
|
|
1294
|
+
</div>
|
|
1295
|
+
`;
|
|
1296
|
+
container.appendChild(list);
|
|
1297
|
+
return;
|
|
1298
|
+
}
|
|
1299
|
+
|
|
1300
|
+
visible.forEach(path => {
|
|
1301
|
+
const item = document.createElement('div');
|
|
1302
|
+
item.className = 'fe-item fe-flat-match';
|
|
1303
|
+
item.dataset.path = path;
|
|
1304
|
+
|
|
1305
|
+
const name = basename(path);
|
|
1306
|
+
const fileType = getFileType(name);
|
|
1307
|
+
const parent = dirname(relativeTo(path, cwd));
|
|
1308
|
+
|
|
1309
|
+
if (state.selectedPath === path) item.classList.add('selected');
|
|
1310
|
+
|
|
1311
|
+
item.innerHTML = `
|
|
1312
|
+
<span class="fe-icon" style="color: ${fileType.color}">${ICONS[fileType.icon] || '📄'}</span>
|
|
1313
|
+
<div class="fe-match-info">
|
|
1314
|
+
<span class="fe-name">${escapeHtml(name)}</span>
|
|
1315
|
+
<span class="fe-match-path">${escapeHtml(parent || '.')}</span>
|
|
1316
|
+
</div>
|
|
1317
|
+
`;
|
|
1318
|
+
|
|
1319
|
+
item.onclick = () => {
|
|
1320
|
+
state.selectedPath = path;
|
|
1321
|
+
window.app?.previewFile(path, { imageGallery: 'dir' });
|
|
1322
|
+
renderList();
|
|
1323
|
+
};
|
|
1324
|
+
item.ondblclick = () => window.app?.openFileInEditor(path, cwd);
|
|
1325
|
+
item.oncontextmenu = (e) => {
|
|
1326
|
+
e.preventDefault();
|
|
1327
|
+
showContextMenu(e, { name, is_dir: false, path }, path);
|
|
1328
|
+
};
|
|
1329
|
+
|
|
1330
|
+
list.appendChild(item);
|
|
1331
|
+
});
|
|
1332
|
+
|
|
1333
|
+
if (matches.length >= FLAT_MATCH_LIMIT) {
|
|
1334
|
+
const more = document.createElement('div');
|
|
1335
|
+
more.className = 'fe-flat-more';
|
|
1336
|
+
more.textContent = `Showing first ${FLAT_MATCH_LIMIT} matches — narrow the query for more`;
|
|
1337
|
+
list.appendChild(more);
|
|
1338
|
+
} else if (state.treeIndexTruncated) {
|
|
1339
|
+
const truncated = document.createElement('div');
|
|
1340
|
+
truncated.className = 'fe-flat-more';
|
|
1341
|
+
truncated.textContent = 'Index truncated at 20,000 files — deep results may be missing';
|
|
1342
|
+
list.appendChild(truncated);
|
|
1343
|
+
}
|
|
1344
|
+
|
|
1345
|
+
container.appendChild(list);
|
|
1346
|
+
}
|
|
1347
|
+
|
|
1348
|
+
// ============================================================================
|
|
1349
|
+
// Main Render Function
|
|
1350
|
+
// ============================================================================
|
|
1351
|
+
|
|
1352
|
+
function renderContent() {
|
|
1353
|
+
const state = getState();
|
|
1354
|
+
const container = state.currentContainer;
|
|
1355
|
+
if (!container) return;
|
|
1356
|
+
|
|
1357
|
+
// Preserve scroll position across full re-renders (expand/collapse/sort
|
|
1358
|
+
// rebuild the DOM; without this the list jumps back to the top).
|
|
1359
|
+
const prevList = container.querySelector('.fe-list');
|
|
1360
|
+
const prevScrollTop = prevList ? prevList.scrollTop : 0;
|
|
1361
|
+
|
|
1362
|
+
container.innerHTML = '';
|
|
1363
|
+
container.className = 'fe-container';
|
|
1364
|
+
|
|
1365
|
+
renderToolbar(container);
|
|
1366
|
+
|
|
1367
|
+
const content = document.createElement('div');
|
|
1368
|
+
content.className = 'fe-content';
|
|
1369
|
+
renderBreadcrumb(content);
|
|
1370
|
+
renderFilterBar(content);
|
|
1371
|
+
renderFileList(content);
|
|
1372
|
+
|
|
1373
|
+
container.appendChild(content);
|
|
1374
|
+
|
|
1375
|
+
// Restore scroll on the new .fe-list after it's in the DOM
|
|
1376
|
+
if (prevScrollTop > 0) {
|
|
1377
|
+
const newList = container.querySelector('.fe-list');
|
|
1378
|
+
if (newList) newList.scrollTop = prevScrollTop;
|
|
1379
|
+
}
|
|
1380
|
+
|
|
1381
|
+
// Update widget summary
|
|
1382
|
+
if (state.widget) {
|
|
1383
|
+
const count = state.files.length;
|
|
1384
|
+
const path = basename(state.currentPath) || 'Files';
|
|
1385
|
+
state.widget.setSummary?.(`${path} (${count})`);
|
|
1386
|
+
}
|
|
1387
|
+
}
|
|
1388
|
+
|
|
1389
|
+
// Partial re-render: rebuilds only the header + list, leaving toolbar,
|
|
1390
|
+
// breadcrumb, and filter bar (and its focus/caret) intact. Called on every
|
|
1391
|
+
// filter keystroke so typing stays responsive.
|
|
1392
|
+
function renderList() {
|
|
1393
|
+
const state = getState();
|
|
1394
|
+
const container = state.currentContainer;
|
|
1395
|
+
if (!container) return;
|
|
1396
|
+
const content = container.querySelector('.fe-content');
|
|
1397
|
+
if (!content) { renderContent(); return; }
|
|
1398
|
+
const prevList = content.querySelector('.fe-list');
|
|
1399
|
+
const prevHeader = content.querySelector('.fe-header-row');
|
|
1400
|
+
const prevScrollTop = prevList ? prevList.scrollTop : 0;
|
|
1401
|
+
if (prevList) prevList.remove();
|
|
1402
|
+
if (prevHeader) prevHeader.remove();
|
|
1403
|
+
renderFileList(content);
|
|
1404
|
+
const newList = content.querySelector('.fe-list');
|
|
1405
|
+
if (newList && prevScrollTop > 0) newList.scrollTop = prevScrollTop;
|
|
1406
|
+
}
|
|
1407
|
+
|
|
1408
|
+
// ============================================================================
|
|
1409
|
+
// Actions & Helpers
|
|
1410
|
+
// ============================================================================
|
|
1411
|
+
|
|
1412
|
+
async function loadDirectory(path) {
|
|
1413
|
+
const state = getState();
|
|
1414
|
+
state.loading = true;
|
|
1415
|
+
state.error = null;
|
|
1416
|
+
state.errorInfo = null;
|
|
1417
|
+
state.navigateTo(path);
|
|
1418
|
+
renderContent();
|
|
1419
|
+
|
|
1420
|
+
try {
|
|
1421
|
+
// Fetch files and git status in parallel — git status is one-shot per cwd
|
|
1422
|
+
const [files] = await Promise.all([
|
|
1423
|
+
fetchDirectory(path),
|
|
1424
|
+
refreshGitStatus(false),
|
|
1425
|
+
]);
|
|
1426
|
+
state.files = files;
|
|
1427
|
+
state.loading = false;
|
|
1428
|
+
renderContent();
|
|
1429
|
+
} catch (err) {
|
|
1430
|
+
state.loading = false;
|
|
1431
|
+
state.error = err.message;
|
|
1432
|
+
state.errorInfo = { status: err.status, detail: err.detail, path: err.path || path };
|
|
1433
|
+
state.files = [];
|
|
1434
|
+
renderContent();
|
|
1435
|
+
}
|
|
1436
|
+
}
|
|
1437
|
+
|
|
1438
|
+
// Refresh the current listing in place — re-fetches the current dir and every
|
|
1439
|
+
// expanded subtree plus git status, then re-renders. Preserves scroll position
|
|
1440
|
+
// (renderContent handles that) and expansion state (state.expanded /
|
|
1441
|
+
// childrenCache are not cleared). Used on window focus / widget open so
|
|
1442
|
+
// external file system changes (e.g. Claude wrote a new file via the terminal)
|
|
1443
|
+
// show up without requiring a manual refresh.
|
|
1444
|
+
//
|
|
1445
|
+
// Concurrent calls are coalesced; calls within 500ms of each other are skipped
|
|
1446
|
+
// unless force=true. The manual refresh button passes force.
|
|
1447
|
+
let _refreshing = null;
|
|
1448
|
+
let _lastRefreshAt = 0;
|
|
1449
|
+
async function refreshInPlace({ force = false } = {}) {
|
|
1450
|
+
const state = getState();
|
|
1451
|
+
if (!state.currentPath) return;
|
|
1452
|
+
if (_refreshing) return _refreshing;
|
|
1453
|
+
if (!force && Date.now() - _lastRefreshAt < 500) return;
|
|
1454
|
+
|
|
1455
|
+
_refreshing = (async () => {
|
|
1456
|
+
const paths = [state.currentPath, ...Array.from(state.expanded)];
|
|
1457
|
+
state.gitFetchedForCwd = null; // force git re-fetch
|
|
1458
|
+
invalidateTreeIndex(); // recursive search index gets rebuilt on next query
|
|
1459
|
+
// We catch into a shape that preserves the error so we can surface it;
|
|
1460
|
+
// a plain null would lose 403 vs 404.
|
|
1461
|
+
const results = await Promise.all([
|
|
1462
|
+
...paths.map(p => fetchDirectory(p).then(
|
|
1463
|
+
files => ({ ok: true, files }),
|
|
1464
|
+
err => ({ ok: false, err }),
|
|
1465
|
+
)),
|
|
1466
|
+
refreshGitStatus(true),
|
|
1467
|
+
]);
|
|
1468
|
+
const topResult = results[0];
|
|
1469
|
+
if (topResult.ok) {
|
|
1470
|
+
state.files = topResult.files;
|
|
1471
|
+
state.errorInfo = null;
|
|
1472
|
+
} else {
|
|
1473
|
+
// The top-level dir itself became inaccessible (perms changed,
|
|
1474
|
+
// deleted, etc.). Show it to the user instead of silently keeping
|
|
1475
|
+
// stale data.
|
|
1476
|
+
state.errorInfo = {
|
|
1477
|
+
status: topResult.err.status,
|
|
1478
|
+
detail: topResult.err.detail,
|
|
1479
|
+
path: state.currentPath,
|
|
1480
|
+
};
|
|
1481
|
+
state.files = [];
|
|
1482
|
+
}
|
|
1483
|
+
for (let i = 1; i < paths.length; i++) {
|
|
1484
|
+
const r = results[i];
|
|
1485
|
+
if (r.ok) {
|
|
1486
|
+
state.childrenCache.set(paths[i], r.files);
|
|
1487
|
+
state.childrenError.delete(paths[i]);
|
|
1488
|
+
} else {
|
|
1489
|
+
// Fetch failed for an expanded subtree — drop from cache and
|
|
1490
|
+
// collapse so stale rows don't linger.
|
|
1491
|
+
state.childrenCache.delete(paths[i]);
|
|
1492
|
+
state.expanded.delete(paths[i]);
|
|
1493
|
+
state.childrenError.delete(paths[i]);
|
|
1494
|
+
}
|
|
1495
|
+
}
|
|
1496
|
+
renderContent();
|
|
1497
|
+
})();
|
|
1498
|
+
|
|
1499
|
+
try { await _refreshing; }
|
|
1500
|
+
finally {
|
|
1501
|
+
_refreshing = null;
|
|
1502
|
+
_lastRefreshAt = Date.now();
|
|
1503
|
+
}
|
|
1504
|
+
}
|
|
1505
|
+
|
|
1506
|
+
function showContextMenu(e, file, path) {
|
|
1507
|
+
const state = getState();
|
|
1508
|
+
// Remove existing context menu
|
|
1509
|
+
document.querySelectorAll('.fe-context-menu').forEach(m => m.remove());
|
|
1510
|
+
|
|
1511
|
+
const menu = document.createElement('div');
|
|
1512
|
+
menu.className = 'fe-context-menu';
|
|
1513
|
+
menu.style.left = `${e.clientX}px`;
|
|
1514
|
+
menu.style.top = `${e.clientY}px`;
|
|
1515
|
+
menu.tabIndex = -1;
|
|
1516
|
+
|
|
1517
|
+
const actions = file.is_dir ? [
|
|
1518
|
+
{ label: S.widgets.file_explorer.context_menu.enter, icon: 'folder', action: () => loadDirectory(path) },
|
|
1519
|
+
{ label: S.widgets.file_explorer.context_menu.copy_path, icon: 'copy', action: () => { navigator.clipboard.writeText(path); showToast(S.toast.copied); } },
|
|
1520
|
+
{ label: S.widgets.file_explorer.context_menu.open_terminal, icon: 'terminal', action: () => openInTerminal(path) }
|
|
1521
|
+
] : [
|
|
1522
|
+
{ label: S.widgets.file_explorer.context_menu.open_editor, icon: 'edit', action: () => window.app?.openFileInEditor(path, state.cwd) },
|
|
1523
|
+
{ label: S.widgets.file_explorer.context_menu.preview, icon: 'eye', action: () => window.app?.previewFile(path, { imageGallery: 'dir' }) },
|
|
1524
|
+
// Only for types /api/browser/render serves with a real MIME — anything
|
|
1525
|
+
// else downloads instead of rendering inside the browser widget's iframe
|
|
1526
|
+
...(BROWSER_RENDERABLE.test(file.name) ? [
|
|
1527
|
+
{ label: S.widgets.file_explorer.context_menu.open_browser, icon: 'globe', action: () => BrowserWidget.navigate(path) }
|
|
1528
|
+
] : []),
|
|
1529
|
+
{ label: S.widgets.file_explorer.context_menu.copy_path, icon: 'copy', action: () => { navigator.clipboard.writeText(path); showToast(S.toast.copied); } },
|
|
1530
|
+
{ label: S.widgets.file_explorer.context_menu.insert_chat, icon: 'message', action: () => insertToChat(`\`${path}\``) },
|
|
1531
|
+
{ label: S.widgets.file_explorer.context_menu.copy_content, icon: 'clipboard', action: () => copyFileContent(path) },
|
|
1532
|
+
// Compare wizard
|
|
1533
|
+
{ label: S.widgets.file_explorer.context_menu.compare, icon: 'columns', action: () => window.DiffViewerWidget?.openCompareWizard(path, state.cwd) }
|
|
1534
|
+
];
|
|
1535
|
+
|
|
1536
|
+
const itemEls = [];
|
|
1537
|
+
let selectedIndex = 0;
|
|
1538
|
+
|
|
1539
|
+
const updateSelection = () => {
|
|
1540
|
+
itemEls.forEach((el, i) => {
|
|
1541
|
+
el.classList.toggle('selected', i === selectedIndex);
|
|
1542
|
+
});
|
|
1543
|
+
itemEls[selectedIndex]?.scrollIntoView({ block: 'nearest' });
|
|
1544
|
+
};
|
|
1545
|
+
|
|
1546
|
+
actions.forEach(({ label, icon, action }, i) => {
|
|
1547
|
+
const item = document.createElement('div');
|
|
1548
|
+
item.className = 'fe-context-item';
|
|
1549
|
+
item.innerHTML = `<span class="fe-context-icon">${ICONS[icon] || ''}</span>${label}`;
|
|
1550
|
+
item.onclick = () => {
|
|
1551
|
+
closeOwnContextMenu();
|
|
1552
|
+
action();
|
|
1553
|
+
};
|
|
1554
|
+
item.onmouseenter = () => {
|
|
1555
|
+
selectedIndex = i;
|
|
1556
|
+
updateSelection();
|
|
1557
|
+
};
|
|
1558
|
+
itemEls.push(item);
|
|
1559
|
+
menu.appendChild(item);
|
|
1560
|
+
});
|
|
1561
|
+
|
|
1562
|
+
document.body.appendChild(menu);
|
|
1563
|
+
// Grab focus so the widget's own keydown listener returns early — type-ahead
|
|
1564
|
+
// and arrow nav on the list shouldn't fire while the menu is open.
|
|
1565
|
+
menu.focus({ preventScroll: true });
|
|
1566
|
+
updateSelection();
|
|
1567
|
+
|
|
1568
|
+
// Keyboard navigation for the menu itself. Listening in the capture phase
|
|
1569
|
+
// ensures we beat both the widget's document-level keydown handler and the
|
|
1570
|
+
// global ShortcutManager for these keys while the menu is open.
|
|
1571
|
+
const handleMenuKey = (e) => {
|
|
1572
|
+
if (e.key === 'ArrowDown') {
|
|
1573
|
+
e.preventDefault();
|
|
1574
|
+
e.stopPropagation();
|
|
1575
|
+
selectedIndex = (selectedIndex + 1) % itemEls.length;
|
|
1576
|
+
updateSelection();
|
|
1577
|
+
} else if (e.key === 'ArrowUp') {
|
|
1578
|
+
e.preventDefault();
|
|
1579
|
+
e.stopPropagation();
|
|
1580
|
+
selectedIndex = (selectedIndex - 1 + itemEls.length) % itemEls.length;
|
|
1581
|
+
updateSelection();
|
|
1582
|
+
} else if (e.key === 'Home') {
|
|
1583
|
+
e.preventDefault();
|
|
1584
|
+
e.stopPropagation();
|
|
1585
|
+
selectedIndex = 0;
|
|
1586
|
+
updateSelection();
|
|
1587
|
+
} else if (e.key === 'End') {
|
|
1588
|
+
e.preventDefault();
|
|
1589
|
+
e.stopPropagation();
|
|
1590
|
+
selectedIndex = itemEls.length - 1;
|
|
1591
|
+
updateSelection();
|
|
1592
|
+
} else if (e.key === 'Enter') {
|
|
1593
|
+
e.preventDefault();
|
|
1594
|
+
e.stopPropagation();
|
|
1595
|
+
itemEls[selectedIndex]?.click();
|
|
1596
|
+
}
|
|
1597
|
+
};
|
|
1598
|
+
document.addEventListener('keydown', handleMenuKey, true);
|
|
1599
|
+
|
|
1600
|
+
// Close on click outside
|
|
1601
|
+
const closeMenu = (e) => {
|
|
1602
|
+
if (!menu.contains(e.target)) {
|
|
1603
|
+
closeOwnContextMenu();
|
|
1604
|
+
}
|
|
1605
|
+
};
|
|
1606
|
+
const cleanup = () => {
|
|
1607
|
+
document.removeEventListener('click', closeMenu);
|
|
1608
|
+
document.removeEventListener('keydown', handleMenuKey, true);
|
|
1609
|
+
menu.__cleanup = null;
|
|
1610
|
+
};
|
|
1611
|
+
menu.__cleanup = cleanup;
|
|
1612
|
+
setTimeout(() => document.addEventListener('click', closeMenu), 0);
|
|
1613
|
+
}
|
|
1614
|
+
|
|
1615
|
+
// Alt+Enter on a selected item: open the same context menu the right-click
|
|
1616
|
+
// gesture would, anchored to the item's rect (since we have no mouse event).
|
|
1617
|
+
function openContextMenuForSelection() {
|
|
1618
|
+
const state = getState();
|
|
1619
|
+
const items = getVisibleItems();
|
|
1620
|
+
if (items.length === 0) return;
|
|
1621
|
+
const el = items.find(i => i.dataset.path === state.selectedPath) || items[0];
|
|
1622
|
+
if (!state.selectedPath) setSelection(el.dataset.path, false);
|
|
1623
|
+
const rect = el.getBoundingClientRect();
|
|
1624
|
+
const pseudoEvent = {
|
|
1625
|
+
clientX: rect.left + 24,
|
|
1626
|
+
clientY: rect.bottom,
|
|
1627
|
+
preventDefault() {}
|
|
1628
|
+
};
|
|
1629
|
+
const absPath = el.dataset.path;
|
|
1630
|
+
const file = { name: basename(absPath), is_dir: el.classList.contains('fe-dir'), path: absPath };
|
|
1631
|
+
showContextMenu(pseudoEvent, file, absPath);
|
|
1632
|
+
}
|
|
1633
|
+
|
|
1634
|
+
// Close the file-explorer-owned context menu. Returns true if one was found.
|
|
1635
|
+
// Restores focus to the widget so arrow keys keep working after the menu closes.
|
|
1636
|
+
function closeOwnContextMenu() {
|
|
1637
|
+
const menu = document.querySelector('.fe-context-menu');
|
|
1638
|
+
if (!menu) return false;
|
|
1639
|
+
menu.__cleanup?.();
|
|
1640
|
+
menu.remove();
|
|
1641
|
+
const state = getState();
|
|
1642
|
+
state.currentContainer?.focus({ preventScroll: true });
|
|
1643
|
+
return true;
|
|
1644
|
+
}
|
|
1645
|
+
|
|
1646
|
+
async function copyFileContent(path) {
|
|
1647
|
+
try {
|
|
1648
|
+
const content = await fetchFileContent(path);
|
|
1649
|
+
await navigator.clipboard.writeText(content);
|
|
1650
|
+
showToast(S.toast.content_copied);
|
|
1651
|
+
} catch (err) {
|
|
1652
|
+
showToast(S.errors.copy_failed);
|
|
1653
|
+
}
|
|
1654
|
+
}
|
|
1655
|
+
|
|
1656
|
+
function insertToChat(text) {
|
|
1657
|
+
const app = window.app;
|
|
1658
|
+
const input = app?.els?.messageInput;
|
|
1659
|
+
if (!input) return;
|
|
1660
|
+
|
|
1661
|
+
// In full-tab mode the chat input is hidden — jump back to the session view
|
|
1662
|
+
if (app.tabCtrl?.activeWidgetTabId && app.tabCtrl.activeSession) {
|
|
1663
|
+
app.tabCtrl.switchToSession(app.tabCtrl.activeSession);
|
|
1664
|
+
}
|
|
1665
|
+
|
|
1666
|
+
// Insert at cursor, padding with spaces so paths don't glue to existing text
|
|
1667
|
+
const start = input.selectionStart ?? input.value.length;
|
|
1668
|
+
const end = input.selectionEnd ?? start;
|
|
1669
|
+
const before = input.value.slice(0, start);
|
|
1670
|
+
const after = input.value.slice(end);
|
|
1671
|
+
const needsSpace = before.length > 0 && !/\s$/.test(before);
|
|
1672
|
+
const insert = (needsSpace ? ' ' : '') + text + ' ';
|
|
1673
|
+
input.value = before + insert + after;
|
|
1674
|
+
const pos = before.length + insert.length;
|
|
1675
|
+
input.setSelectionRange(pos, pos);
|
|
1676
|
+
input.focus();
|
|
1677
|
+
app.autoResizeInput?.();
|
|
1678
|
+
}
|
|
1679
|
+
|
|
1680
|
+
function openInTerminal(path) {
|
|
1681
|
+
// New terminal tab already cd'd into the directory
|
|
1682
|
+
window.app?.tabCtrl?.openTerminalWidgetTab({ cwd: path });
|
|
1683
|
+
}
|
|
1684
|
+
|
|
1685
|
+
function showToast(message) {
|
|
1686
|
+
// Simple toast notification
|
|
1687
|
+
const toast = document.createElement('div');
|
|
1688
|
+
toast.className = 'fe-toast';
|
|
1689
|
+
toast.textContent = message;
|
|
1690
|
+
document.body.appendChild(toast);
|
|
1691
|
+
setTimeout(() => toast.remove(), 2000);
|
|
1692
|
+
}
|
|
1693
|
+
|
|
1694
|
+
// ============================================================================
|
|
1695
|
+
// Utilities
|
|
1696
|
+
// ============================================================================
|
|
1697
|
+
|
|
1698
|
+
// ============================================================================
|
|
1699
|
+
// Widget Registration
|
|
1700
|
+
// ============================================================================
|
|
1701
|
+
|
|
1702
|
+
export function registerFileExplorerWidget() {
|
|
1703
|
+
WidgetManager.register('file-explorer', {
|
|
1704
|
+
type: 'top-sheet',
|
|
1705
|
+
title: S.widgets.titles.files,
|
|
1706
|
+
icon: 'folder',
|
|
1707
|
+
shortcut: 'Alt+F',
|
|
1708
|
+
|
|
1709
|
+
deviceTypes: {
|
|
1710
|
+
default: 'top-sheet',
|
|
1711
|
+
phone: 'top-sheet',
|
|
1712
|
+
tablet: 'top-sheet',
|
|
1713
|
+
desktop: 'floating'
|
|
1714
|
+
},
|
|
1715
|
+
|
|
1716
|
+
heights: { half: '45vh', full: '85vh' },
|
|
1717
|
+
width: '320px',
|
|
1718
|
+
minWidth: '280px',
|
|
1719
|
+
maxWidth: '500px',
|
|
1720
|
+
|
|
1721
|
+
sessionAware: true,
|
|
1722
|
+
persistState: true,
|
|
1723
|
+
allowTransform: true,
|
|
1724
|
+
allowedTypes: ['top-sheet', 'bottom-sheet', 'sidebar-left', 'sidebar-right', 'floating', 'tab'],
|
|
1725
|
+
|
|
1726
|
+
headerActions: [
|
|
1727
|
+
{
|
|
1728
|
+
icon: 'home',
|
|
1729
|
+
title: S.widgets.header_actions.go_home,
|
|
1730
|
+
onClick: () => {
|
|
1731
|
+
const state = getState();
|
|
1732
|
+
loadDirectory(state.cwd || '/');
|
|
1733
|
+
}
|
|
1734
|
+
},
|
|
1735
|
+
{
|
|
1736
|
+
icon: 'search',
|
|
1737
|
+
title: S.widgets.header_actions.search_files,
|
|
1738
|
+
onClick: () => {
|
|
1739
|
+
openFilterBar();
|
|
1740
|
+
}
|
|
1741
|
+
}
|
|
1742
|
+
],
|
|
1743
|
+
|
|
1744
|
+
render: (container, context) => {
|
|
1745
|
+
const state = getState(context.sessionId);
|
|
1746
|
+
state.currentContainer = container;
|
|
1747
|
+
state.widget = WidgetManager.get('file-explorer');
|
|
1748
|
+
|
|
1749
|
+
// Focusable so type-ahead / Ctrl+F can target the widget without
|
|
1750
|
+
// the user having to click into it first.
|
|
1751
|
+
container.tabIndex = -1;
|
|
1752
|
+
|
|
1753
|
+
// Update CWD if session context changed
|
|
1754
|
+
if (context.cwd && context.cwd !== state.cwd) {
|
|
1755
|
+
state.cwd = context.cwd;
|
|
1756
|
+
invalidateTreeIndex();
|
|
1757
|
+
}
|
|
1758
|
+
|
|
1759
|
+
// Determine if we need to load
|
|
1760
|
+
const needsLoad = !state.loading && (
|
|
1761
|
+
// No path set - use session CWD
|
|
1762
|
+
(!state.currentPath && state.cwd) ||
|
|
1763
|
+
// Path is set but no files loaded
|
|
1764
|
+
(state.currentPath && state.files.length === 0)
|
|
1765
|
+
);
|
|
1766
|
+
|
|
1767
|
+
if (needsLoad) {
|
|
1768
|
+
const pathToLoad = state.currentPath || state.cwd;
|
|
1769
|
+
if (pathToLoad) {
|
|
1770
|
+
state.currentPath = pathToLoad;
|
|
1771
|
+
loadDirectory(pathToLoad);
|
|
1772
|
+
} else {
|
|
1773
|
+
renderContent();
|
|
1774
|
+
}
|
|
1775
|
+
} else {
|
|
1776
|
+
renderContent();
|
|
1777
|
+
}
|
|
1778
|
+
},
|
|
1779
|
+
|
|
1780
|
+
onOpen: () => {
|
|
1781
|
+
const state = getState();
|
|
1782
|
+
if (!state.currentPath && state.cwd) {
|
|
1783
|
+
loadDirectory(state.cwd);
|
|
1784
|
+
} else if (state.currentPath) {
|
|
1785
|
+
// Re-opened after being closed: pull latest listing so files
|
|
1786
|
+
// created/deleted while the widget was hidden show up.
|
|
1787
|
+
refreshInPlace();
|
|
1788
|
+
}
|
|
1789
|
+
// Move focus into the widget so keyboard shortcuts (Ctrl+F) and
|
|
1790
|
+
// type-ahead target it instead of the underlying chat input.
|
|
1791
|
+
if (state.currentContainer) {
|
|
1792
|
+
state.currentContainer.focus({ preventScroll: true });
|
|
1793
|
+
}
|
|
1794
|
+
},
|
|
1795
|
+
|
|
1796
|
+
onRefresh: () => refreshInPlace({ force: true }),
|
|
1797
|
+
|
|
1798
|
+
onDestroy: (sessionId) => {
|
|
1799
|
+
destroyState(sessionId);
|
|
1800
|
+
}
|
|
1801
|
+
});
|
|
1802
|
+
|
|
1803
|
+
// Set up event subscriptions
|
|
1804
|
+
setupEventSubscriptions();
|
|
1805
|
+
setupKeyboardNavigation();
|
|
1806
|
+
}
|
|
1807
|
+
|
|
1808
|
+
// ============================================================================
|
|
1809
|
+
// Event Bus Subscriptions
|
|
1810
|
+
// ============================================================================
|
|
1811
|
+
|
|
1812
|
+
function setupEventSubscriptions() {
|
|
1813
|
+
// Listen for file open requests from other widgets
|
|
1814
|
+
WidgetBus.on('navigate:directory', ({ path }) => {
|
|
1815
|
+
loadDirectory(path);
|
|
1816
|
+
WidgetManager.open('file-explorer');
|
|
1817
|
+
});
|
|
1818
|
+
|
|
1819
|
+
// Listen for chat insert requests from other widgets
|
|
1820
|
+
WidgetBus.on('chat:insert', ({ text }) => insertToChat(text));
|
|
1821
|
+
|
|
1822
|
+
// When the file preview closes, hand focus back to the explorer so the
|
|
1823
|
+
// user can keep arrow-navigating — otherwise the preview's Escape leaves
|
|
1824
|
+
// focus on body, which breaks our keydown listener.
|
|
1825
|
+
WidgetBus.on('widget:closed', ({ widgetId }) => {
|
|
1826
|
+
if (widgetId !== 'file-preview') return;
|
|
1827
|
+
const widget = WidgetManager.get('file-explorer');
|
|
1828
|
+
if (!widget?.isVisible) return;
|
|
1829
|
+
const state = getState();
|
|
1830
|
+
state.currentContainer?.focus({ preventScroll: true });
|
|
1831
|
+
});
|
|
1832
|
+
|
|
1833
|
+
// Smart refresh: when the user switches back to the app (focus or
|
|
1834
|
+
// visibilitychange), pull a fresh listing. Catches the "I just created a
|
|
1835
|
+
// file in the terminal and swapped back" case without requiring a manual
|
|
1836
|
+
// click. Debounce inside refreshInPlace prevents spam.
|
|
1837
|
+
const refreshIfVisible = () => {
|
|
1838
|
+
const widget = WidgetManager.get('file-explorer');
|
|
1839
|
+
if (widget?.isVisible) refreshInPlace();
|
|
1840
|
+
};
|
|
1841
|
+
window.addEventListener('focus', refreshIfVisible);
|
|
1842
|
+
document.addEventListener('visibilitychange', () => {
|
|
1843
|
+
if (document.visibilityState === 'visible') refreshIfVisible();
|
|
1844
|
+
});
|
|
1845
|
+
}
|
|
1846
|
+
|
|
1847
|
+
// ============================================================================
|
|
1848
|
+
// Keyboard Navigation
|
|
1849
|
+
// ============================================================================
|
|
1850
|
+
|
|
1851
|
+
// Flat list of currently visible (rendered) items with a resolvable path.
|
|
1852
|
+
// Skips the ".." parent row and placeholder rows (loading/empty).
|
|
1853
|
+
function getVisibleItems() {
|
|
1854
|
+
const state = getState();
|
|
1855
|
+
const container = state.currentContainer;
|
|
1856
|
+
if (!container) return [];
|
|
1857
|
+
return Array.from(container.querySelectorAll('.fe-list .fe-item[data-path]'));
|
|
1858
|
+
}
|
|
1859
|
+
|
|
1860
|
+
// Move the keyboard-selection highlight to `path`. Updates DOM directly so we
|
|
1861
|
+
// don't pay for a full re-render on every arrow keypress.
|
|
1862
|
+
function setSelection(path, scroll = true) {
|
|
1863
|
+
const state = getState();
|
|
1864
|
+
state.selectedPath = path;
|
|
1865
|
+
const container = state.currentContainer;
|
|
1866
|
+
if (!container) return;
|
|
1867
|
+
container.querySelectorAll('.fe-item.selected').forEach(i => i.classList.remove('selected'));
|
|
1868
|
+
if (!path) return;
|
|
1869
|
+
const el = container.querySelector(`.fe-item[data-path="${CSS.escape(path)}"]`);
|
|
1870
|
+
if (el) {
|
|
1871
|
+
el.classList.add('selected');
|
|
1872
|
+
if (scroll) el.scrollIntoView({ block: 'nearest' });
|
|
1873
|
+
}
|
|
1874
|
+
}
|
|
1875
|
+
|
|
1876
|
+
// ArrowUp/Down traversal across visible items. ArrowUp past the first item
|
|
1877
|
+
// returns focus to the filter input (if open), so ArrowDown-into-tree-and-back
|
|
1878
|
+
// feels symmetric.
|
|
1879
|
+
function navigateSelection(delta) {
|
|
1880
|
+
const state = getState();
|
|
1881
|
+
const items = getVisibleItems();
|
|
1882
|
+
if (items.length === 0) return;
|
|
1883
|
+
|
|
1884
|
+
let idx = items.findIndex(el => el.dataset.path === state.selectedPath);
|
|
1885
|
+
if (idx < 0) idx = delta > 0 ? -1 : items.length;
|
|
1886
|
+
const nextIdx = idx + delta;
|
|
1887
|
+
|
|
1888
|
+
if (nextIdx < 0 && state.filterBarOpen) {
|
|
1889
|
+
const input = state.currentContainer?.querySelector('.fe-filter-input');
|
|
1890
|
+
if (input) {
|
|
1891
|
+
setSelection(null);
|
|
1892
|
+
input.focus();
|
|
1893
|
+
input.setSelectionRange(input.value.length, input.value.length);
|
|
1894
|
+
return;
|
|
1895
|
+
}
|
|
1896
|
+
}
|
|
1897
|
+
|
|
1898
|
+
const clamped = Math.max(0, Math.min(items.length - 1, nextIdx));
|
|
1899
|
+
setSelection(items[clamped].dataset.path);
|
|
1900
|
+
}
|
|
1901
|
+
|
|
1902
|
+
// Enter: on a dir, toggle expansion; on a file, preview.
|
|
1903
|
+
function activateSelection() {
|
|
1904
|
+
const state = getState();
|
|
1905
|
+
const items = getVisibleItems();
|
|
1906
|
+
if (items.length === 0) return;
|
|
1907
|
+
let el = items.find(i => i.dataset.path === state.selectedPath);
|
|
1908
|
+
if (!el) {
|
|
1909
|
+
el = items[0];
|
|
1910
|
+
setSelection(el.dataset.path);
|
|
1911
|
+
}
|
|
1912
|
+
const absPath = el.dataset.path;
|
|
1913
|
+
if (el.classList.contains('fe-dir')) {
|
|
1914
|
+
toggleExpanded(absPath);
|
|
1915
|
+
} else {
|
|
1916
|
+
window.app?.previewFile(absPath, { imageGallery: 'dir' });
|
|
1917
|
+
}
|
|
1918
|
+
}
|
|
1919
|
+
|
|
1920
|
+
// ArrowRight: expand a collapsed dir; on an expanded dir, step into first child.
|
|
1921
|
+
function handleArrowRight() {
|
|
1922
|
+
const state = getState();
|
|
1923
|
+
if (!state.selectedPath) return;
|
|
1924
|
+
const container = state.currentContainer;
|
|
1925
|
+
const el = container?.querySelector(`.fe-item[data-path="${CSS.escape(state.selectedPath)}"]`);
|
|
1926
|
+
if (!el || !el.classList.contains('fe-dir')) return;
|
|
1927
|
+
if (!state.expanded.has(state.selectedPath)) {
|
|
1928
|
+
toggleExpanded(state.selectedPath);
|
|
1929
|
+
} else {
|
|
1930
|
+
navigateSelection(1);
|
|
1931
|
+
}
|
|
1932
|
+
}
|
|
1933
|
+
|
|
1934
|
+
// ArrowLeft: collapse expanded dir; otherwise jump to parent dir row.
|
|
1935
|
+
function handleArrowLeft() {
|
|
1936
|
+
const state = getState();
|
|
1937
|
+
if (!state.selectedPath) return;
|
|
1938
|
+
const container = state.currentContainer;
|
|
1939
|
+
const el = container?.querySelector(`.fe-item[data-path="${CSS.escape(state.selectedPath)}"]`);
|
|
1940
|
+
if (!el) return;
|
|
1941
|
+
const isDir = el.classList.contains('fe-dir');
|
|
1942
|
+
if (isDir && state.expanded.has(state.selectedPath)) {
|
|
1943
|
+
toggleExpanded(state.selectedPath);
|
|
1944
|
+
return;
|
|
1945
|
+
}
|
|
1946
|
+
const parent = parentOf(state.selectedPath);
|
|
1947
|
+
if (!parent) return;
|
|
1948
|
+
const parentEl = container?.querySelector(`.fe-item[data-path="${CSS.escape(parent)}"]`);
|
|
1949
|
+
if (parentEl) setSelection(parent);
|
|
1950
|
+
}
|
|
1951
|
+
|
|
1952
|
+
function setupKeyboardNavigation() {
|
|
1953
|
+
// Note: Ctrl/Cmd+F and Escape are handled by app.openSearch() and
|
|
1954
|
+
// app.handleEscape() — the global ShortcutManager intercepts those keys in
|
|
1955
|
+
// the capture phase with stopImmediatePropagation, so we can't catch them
|
|
1956
|
+
// here. Type-ahead (printable keys) and list nav (arrows, Backspace, Enter)
|
|
1957
|
+
// aren't registered shortcuts, so they fall through to this bubble listener.
|
|
1958
|
+
document.addEventListener('keydown', (e) => {
|
|
1959
|
+
const widget = WidgetManager.get('file-explorer');
|
|
1960
|
+
if (!widget?.isVisible) return;
|
|
1961
|
+
|
|
1962
|
+
const state = getState();
|
|
1963
|
+
const container = state.currentContainer;
|
|
1964
|
+
if (!container?.contains(document.activeElement) && document.activeElement !== document.body) return;
|
|
1965
|
+
|
|
1966
|
+
const inInput = document.activeElement?.matches('input, textarea');
|
|
1967
|
+
|
|
1968
|
+
// Type-ahead: printable char in list view opens the inline filter bar
|
|
1969
|
+
// and pre-populates it. '/' opens the bar empty (classic "start search").
|
|
1970
|
+
// If the filter bar is already open (but focus is on the tree), append
|
|
1971
|
+
// to the existing query and return focus to the input.
|
|
1972
|
+
const isPrintable = e.key.length === 1 && !e.ctrlKey && !e.metaKey && !e.altKey;
|
|
1973
|
+
if (isPrintable && !inInput) {
|
|
1974
|
+
e.preventDefault();
|
|
1975
|
+
if (state.filterBarOpen) {
|
|
1976
|
+
const input = container.querySelector('.fe-filter-input');
|
|
1977
|
+
if (input) {
|
|
1978
|
+
state.filterQuery = (state.filterQuery || '') + e.key;
|
|
1979
|
+
input.value = state.filterQuery;
|
|
1980
|
+
input.focus();
|
|
1981
|
+
input.setSelectionRange(input.value.length, input.value.length);
|
|
1982
|
+
renderList();
|
|
1983
|
+
}
|
|
1984
|
+
} else {
|
|
1985
|
+
openFilterBar(e.key === '/' ? '' : e.key);
|
|
1986
|
+
}
|
|
1987
|
+
return;
|
|
1988
|
+
}
|
|
1989
|
+
|
|
1990
|
+
// Filter input owns its own ArrowUp/Down/Enter (see renderFilterBar).
|
|
1991
|
+
if (inInput) return;
|
|
1992
|
+
|
|
1993
|
+
switch (e.key) {
|
|
1994
|
+
case 'ArrowUp':
|
|
1995
|
+
e.preventDefault();
|
|
1996
|
+
navigateSelection(-1);
|
|
1997
|
+
break;
|
|
1998
|
+
case 'ArrowDown':
|
|
1999
|
+
e.preventDefault();
|
|
2000
|
+
navigateSelection(1);
|
|
2001
|
+
break;
|
|
2002
|
+
case 'ArrowRight':
|
|
2003
|
+
e.preventDefault();
|
|
2004
|
+
handleArrowRight();
|
|
2005
|
+
break;
|
|
2006
|
+
case 'ArrowLeft':
|
|
2007
|
+
e.preventDefault();
|
|
2008
|
+
handleArrowLeft();
|
|
2009
|
+
break;
|
|
2010
|
+
case 'Enter':
|
|
2011
|
+
e.preventDefault();
|
|
2012
|
+
if (e.altKey) {
|
|
2013
|
+
openContextMenuForSelection();
|
|
2014
|
+
} else {
|
|
2015
|
+
activateSelection();
|
|
2016
|
+
}
|
|
2017
|
+
break;
|
|
2018
|
+
case 'Backspace': {
|
|
2019
|
+
e.preventDefault();
|
|
2020
|
+
// If filtering, Backspace pops the last char (rather than
|
|
2021
|
+
// navigating up a directory — which is surprising mid-filter).
|
|
2022
|
+
if (state.filterBarOpen && state.filterQuery) {
|
|
2023
|
+
state.filterQuery = state.filterQuery.slice(0, -1);
|
|
2024
|
+
const input = container.querySelector('.fe-filter-input');
|
|
2025
|
+
if (input) input.value = state.filterQuery;
|
|
2026
|
+
renderList();
|
|
2027
|
+
break;
|
|
2028
|
+
}
|
|
2029
|
+
const parentPath = parentOf(state.currentPath) || pathRoot(state.currentPath) || '/';
|
|
2030
|
+
if (parentPath !== state.currentPath) {
|
|
2031
|
+
loadDirectory(parentPath);
|
|
2032
|
+
}
|
|
2033
|
+
break;
|
|
2034
|
+
}
|
|
2035
|
+
}
|
|
2036
|
+
});
|
|
2037
|
+
}
|
|
2038
|
+
|
|
2039
|
+
// ============================================================================
|
|
2040
|
+
// External API (for backward compatibility with app.js)
|
|
2041
|
+
// ============================================================================
|
|
2042
|
+
|
|
2043
|
+
/**
|
|
2044
|
+
* FileExplorerWidget API - provides methods for external control
|
|
2045
|
+
*/
|
|
2046
|
+
export const FileExplorerWidget = {
|
|
2047
|
+
/** Open the file explorer */
|
|
2048
|
+
open() {
|
|
2049
|
+
WidgetManager.open('file-explorer');
|
|
2050
|
+
},
|
|
2051
|
+
|
|
2052
|
+
/** Close the file explorer */
|
|
2053
|
+
close() {
|
|
2054
|
+
WidgetManager.close('file-explorer');
|
|
2055
|
+
},
|
|
2056
|
+
|
|
2057
|
+
/** Toggle the file explorer */
|
|
2058
|
+
toggle() {
|
|
2059
|
+
WidgetManager.toggle('file-explorer');
|
|
2060
|
+
},
|
|
2061
|
+
|
|
2062
|
+
/** Get current state */
|
|
2063
|
+
get state() {
|
|
2064
|
+
const widget = WidgetManager.get('file-explorer');
|
|
2065
|
+
return widget?.state || 'collapsed';
|
|
2066
|
+
},
|
|
2067
|
+
|
|
2068
|
+
/** Check if visible */
|
|
2069
|
+
get isVisible() {
|
|
2070
|
+
const widget = WidgetManager.get('file-explorer');
|
|
2071
|
+
return widget?.isVisible || false;
|
|
2072
|
+
},
|
|
2073
|
+
|
|
2074
|
+
/** Is focus inside the widget (or on body while widget is open)? */
|
|
2075
|
+
isFocused() {
|
|
2076
|
+
const widget = WidgetManager.get('file-explorer');
|
|
2077
|
+
if (!widget?.isVisible) return false;
|
|
2078
|
+
const state = getState();
|
|
2079
|
+
const container = state.currentContainer;
|
|
2080
|
+
if (!container) return false;
|
|
2081
|
+
return container.contains(document.activeElement) || document.activeElement === document.body;
|
|
2082
|
+
},
|
|
2083
|
+
|
|
2084
|
+
/** Open the inline filter bar and focus its input. Returns true if applied. */
|
|
2085
|
+
openSearch() {
|
|
2086
|
+
const widget = WidgetManager.get('file-explorer');
|
|
2087
|
+
if (!widget?.isVisible) return false;
|
|
2088
|
+
openFilterBar();
|
|
2089
|
+
return true;
|
|
2090
|
+
},
|
|
2091
|
+
|
|
2092
|
+
/**
|
|
2093
|
+
* Escape handler. Priority (highest first):
|
|
2094
|
+
* 1. Close the file-explorer context menu if open
|
|
2095
|
+
* 2. Cancel the breadcrumb path editor if active
|
|
2096
|
+
* 3. Close the inline filter bar if open
|
|
2097
|
+
* Returns true if any of these was handled.
|
|
2098
|
+
*/
|
|
2099
|
+
handleEscape() {
|
|
2100
|
+
const widget = WidgetManager.get('file-explorer');
|
|
2101
|
+
if (!widget?.isVisible) return false;
|
|
2102
|
+
if (closeOwnContextMenu()) return true;
|
|
2103
|
+
const state = getState();
|
|
2104
|
+
if (state.pathEditing) { cancelPathEdit(); return true; }
|
|
2105
|
+
if (!state.filterBarOpen) return false;
|
|
2106
|
+
closeFilterBar();
|
|
2107
|
+
return true;
|
|
2108
|
+
},
|
|
2109
|
+
|
|
2110
|
+
/** Set home path (CWD) */
|
|
2111
|
+
setHomePath(path) {
|
|
2112
|
+
const state = getState();
|
|
2113
|
+
state.cwd = path;
|
|
2114
|
+
if (!state.currentPath) {
|
|
2115
|
+
state.currentPath = path;
|
|
2116
|
+
}
|
|
2117
|
+
},
|
|
2118
|
+
|
|
2119
|
+
/** Navigate to directory */
|
|
2120
|
+
navigateTo(path) {
|
|
2121
|
+
loadDirectory(path);
|
|
2122
|
+
WidgetManager.open('file-explorer');
|
|
2123
|
+
},
|
|
2124
|
+
|
|
2125
|
+
/**
|
|
2126
|
+
* Reveal a file: open the explorer at its parent directory, select the
|
|
2127
|
+
* row, scroll it into view, and flash it. PWA-friendly alternative to a
|
|
2128
|
+
* host-OS file manager — works on iPad and headless servers alike.
|
|
2129
|
+
*/
|
|
2130
|
+
async revealFile(path) {
|
|
2131
|
+
if (!path) return;
|
|
2132
|
+
const parentDir = parentOf(path) || pathRoot(path) || '/';
|
|
2133
|
+
|
|
2134
|
+
// Open the widget first so state.currentContainer is set before
|
|
2135
|
+
// loadDirectory's renderContent runs. Wait one frame for the render
|
|
2136
|
+
// callback to commit the container reference.
|
|
2137
|
+
WidgetManager.open('file-explorer');
|
|
2138
|
+
await new Promise(r => requestAnimationFrame(r));
|
|
2139
|
+
|
|
2140
|
+
await loadDirectory(parentDir);
|
|
2141
|
+
|
|
2142
|
+
// Wait one more frame so the row DOM is in place before selecting.
|
|
2143
|
+
await new Promise(r => requestAnimationFrame(r));
|
|
2144
|
+
|
|
2145
|
+
setSelection(path, true);
|
|
2146
|
+
const container = getState().currentContainer;
|
|
2147
|
+
const el = container?.querySelector(`.fe-item[data-path="${CSS.escape(path)}"]`);
|
|
2148
|
+
if (el) {
|
|
2149
|
+
el.classList.add('flash');
|
|
2150
|
+
setTimeout(() => el.classList.remove('flash'), 1500);
|
|
2151
|
+
}
|
|
2152
|
+
},
|
|
2153
|
+
|
|
2154
|
+
/** Force an in-place refresh (re-fetches current dir + expanded subtrees + git) */
|
|
2155
|
+
refresh() {
|
|
2156
|
+
return refreshInPlace({ force: true });
|
|
2157
|
+
},
|
|
2158
|
+
|
|
2159
|
+
/** Get internal state for debugging */
|
|
2160
|
+
getState() {
|
|
2161
|
+
return getState();
|
|
2162
|
+
},
|
|
2163
|
+
|
|
2164
|
+
/** Destroy state for a session */
|
|
2165
|
+
destroyState
|
|
2166
|
+
};
|