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,1420 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Diff Viewer Widget - Side-by-side file comparison
|
|
3
|
+
*
|
|
4
|
+
* Provides a two-column diff view using CSS Grid (single scroll container,
|
|
5
|
+
* perfect line alignment). Supports multiple entry points:
|
|
6
|
+
* - Edit tool blocks (automatic old/new)
|
|
7
|
+
* - Changes widget (session file changes)
|
|
8
|
+
* - Git widget (HEAD vs working tree)
|
|
9
|
+
* - Compare Wizard (shadow history, git history, branches, working changes, file picker)
|
|
10
|
+
*
|
|
11
|
+
* Views: split (side-by-side) | unified (classic)
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import S from '../strings.js';
|
|
15
|
+
import { escapeHtml } from '../utils.js';
|
|
16
|
+
import { CONFIG } from '../config.js';
|
|
17
|
+
import { WidgetManager, ICONS } from '../widget-system/index.js';
|
|
18
|
+
import { generateSmartDiff, renderSmartDiff, renderSideBySideDiff } from '../diff-utils.js';
|
|
19
|
+
import { showToast, ContextMenu } from '../context-menu.js';
|
|
20
|
+
import { basename, isAbsolutePath, joinPath, relativeTo } from '../path-utils.js';
|
|
21
|
+
|
|
22
|
+
const PREF_KEY_VIEW_MODE = 'diff-viewer-view-mode';
|
|
23
|
+
const PREF_KEY_WRAP_LINES = 'diff-viewer-wrap-lines';
|
|
24
|
+
|
|
25
|
+
function loadViewMode() {
|
|
26
|
+
try {
|
|
27
|
+
const v = localStorage.getItem(PREF_KEY_VIEW_MODE);
|
|
28
|
+
return v === 'unified' || v === 'split' ? v : null;
|
|
29
|
+
} catch { return null; }
|
|
30
|
+
}
|
|
31
|
+
function saveViewMode(v) {
|
|
32
|
+
try { localStorage.setItem(PREF_KEY_VIEW_MODE, v); } catch {}
|
|
33
|
+
}
|
|
34
|
+
function loadWrapLines() {
|
|
35
|
+
try { return localStorage.getItem(PREF_KEY_WRAP_LINES) === '1'; } catch { return false; }
|
|
36
|
+
}
|
|
37
|
+
function saveWrapLines(v) {
|
|
38
|
+
try { localStorage.setItem(PREF_KEY_WRAP_LINES, v ? '1' : '0'); } catch {}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// ═══════════════════════════════════════════════════════════════
|
|
42
|
+
// State
|
|
43
|
+
// ═══════════════════════════════════════════════════════════════
|
|
44
|
+
|
|
45
|
+
class DiffViewerState {
|
|
46
|
+
constructor() { this.reset(); }
|
|
47
|
+
reset() {
|
|
48
|
+
this.files = []; // [{path, oldContent, newContent, oldLabel, newLabel, additions, deletions}]
|
|
49
|
+
this.currentFileIndex = 0;
|
|
50
|
+
this.viewMode = loadViewMode() || 'split'; // 'split' | 'unified'
|
|
51
|
+
this.wrapLines = loadWrapLines();
|
|
52
|
+
this.source = null; // 'edit-tool' | 'git' | 'diff' | 'compare' | 'wizard'
|
|
53
|
+
this.loading = false;
|
|
54
|
+
this.error = null;
|
|
55
|
+
this.currentContainer = null;
|
|
56
|
+
this.hunkElements = []; // cached hunk start elements for keyboard nav
|
|
57
|
+
this.currentHunkIndex = -1;
|
|
58
|
+
|
|
59
|
+
// History bar state
|
|
60
|
+
this.historyCommits = null; // null = not loaded; [] = no history; [...] = loaded
|
|
61
|
+
this.historyLoading = false;
|
|
62
|
+
this.historyKey = null; // `${cwd}:${path}` of last loaded history
|
|
63
|
+
this.historyIndex = -1; // -1 = not aligned to a shadow commit; else index into historyCommits
|
|
64
|
+
|
|
65
|
+
// Wizard state
|
|
66
|
+
this.wizardActive = false;
|
|
67
|
+
this.wizardStep = 'source'; // 'source' | 'shadow' | 'git' | 'branch' | 'working' | 'file'
|
|
68
|
+
this.wizardFilePath = null;
|
|
69
|
+
this.wizardFileContent = null;
|
|
70
|
+
this.wizardCwd = null;
|
|
71
|
+
this.wizardFocusIndex = -1; // keyboard nav focus within lists/cards
|
|
72
|
+
this.wizardPickerData = []; // loaded commits/branches/files
|
|
73
|
+
this.wizardFileQuery = ''; // search filter for file picker
|
|
74
|
+
this.wizardAllFiles = null; // cached full file list
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const states = new Map();
|
|
79
|
+
|
|
80
|
+
function getState(sessionId) {
|
|
81
|
+
sessionId = sessionId || WidgetManager.currentSessionId || '_global';
|
|
82
|
+
if (!states.has(sessionId)) states.set(sessionId, new DiffViewerState());
|
|
83
|
+
return states.get(sessionId);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function destroyState(sessionId) {
|
|
87
|
+
states.delete(sessionId);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// ═══════════════════════════════════════════════════════════════
|
|
91
|
+
// Edit data cache (for tool-renderer onclick passing)
|
|
92
|
+
// ═══════════════════════════════════════════════════════════════
|
|
93
|
+
|
|
94
|
+
const editDataCache = new Map();
|
|
95
|
+
let editDataCounter = 0;
|
|
96
|
+
|
|
97
|
+
function cacheEditData(filePath, oldContent, newContent, startLine) {
|
|
98
|
+
const id = `edit-${++editDataCounter}`;
|
|
99
|
+
editDataCache.set(id, { filePath, oldContent, newContent, startLine });
|
|
100
|
+
// Limit cache size
|
|
101
|
+
if (editDataCache.size > 200) {
|
|
102
|
+
const first = editDataCache.keys().next().value;
|
|
103
|
+
editDataCache.delete(first);
|
|
104
|
+
}
|
|
105
|
+
return id;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function openFromCache(cacheId) {
|
|
109
|
+
const data = editDataCache.get(cacheId);
|
|
110
|
+
if (!data) return;
|
|
111
|
+
DiffViewerWidget.openWithContent(data.filePath, data.oldContent, data.newContent, {
|
|
112
|
+
source: 'edit-tool',
|
|
113
|
+
startLine: data.startLine
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// ═══════════════════════════════════════════════════════════════
|
|
118
|
+
// Compare Wizard
|
|
119
|
+
// ═══════════════════════════════════════════════════════════════
|
|
120
|
+
|
|
121
|
+
const WIZARD_SOURCES = [
|
|
122
|
+
{ id: 'shadow', icon: 'history', titleKey: 'source_shadow', descKey: 'source_shadow_desc' },
|
|
123
|
+
{ id: 'git', icon: 'commit', titleKey: 'source_git', descKey: 'source_git_desc' },
|
|
124
|
+
{ id: 'branch', icon: 'branch', titleKey: 'source_branch', descKey: 'source_branch_desc' },
|
|
125
|
+
{ id: 'working',icon: 'edit', titleKey: 'source_working', descKey: 'source_working_desc' },
|
|
126
|
+
{ id: 'file', icon: 'file', titleKey: 'source_file', descKey: 'source_file_desc' },
|
|
127
|
+
];
|
|
128
|
+
|
|
129
|
+
function renderWizard(sessionId) {
|
|
130
|
+
const state = getState(sessionId);
|
|
131
|
+
const container = state.currentContainer;
|
|
132
|
+
if (!container) return;
|
|
133
|
+
|
|
134
|
+
if (state.wizardStep === 'source') {
|
|
135
|
+
renderWizardSourcePicker(state, container, sessionId);
|
|
136
|
+
} else {
|
|
137
|
+
renderWizardPicker(state, container, sessionId);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// Update widget summary
|
|
141
|
+
const widget = WidgetManager.get('diff-viewer');
|
|
142
|
+
if (widget) {
|
|
143
|
+
const W = S.widgets.diff_viewer.wizard;
|
|
144
|
+
widget.setSummary?.(W.choose_source);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
container.tabIndex = -1;
|
|
148
|
+
container.focus();
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function renderWizardSourcePicker(state, container, sessionId) {
|
|
152
|
+
const W = S.widgets.diff_viewer.wizard;
|
|
153
|
+
const fileName = basename(state.wizardFilePath);
|
|
154
|
+
|
|
155
|
+
let cardsHtml = '';
|
|
156
|
+
WIZARD_SOURCES.forEach((src, i) => {
|
|
157
|
+
const focused = i === state.wizardFocusIndex ? ' cw-focused' : '';
|
|
158
|
+
cardsHtml += `
|
|
159
|
+
<button class="cw-source-card${focused}" data-source="${src.id}" data-idx="${i}">
|
|
160
|
+
<span class="cw-source-icon">${ICONS[src.icon] || ''}<span class="cw-source-title">${W[src.titleKey]}</span></span>
|
|
161
|
+
<span class="cw-source-desc">${W[src.descKey]}</span>
|
|
162
|
+
</button>`;
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
container.innerHTML = `
|
|
166
|
+
<div class="cw-wizard">
|
|
167
|
+
<div class="cw-file-header">
|
|
168
|
+
<span class="cw-file-name">${escapeHtml(fileName)}</span>
|
|
169
|
+
<span class="cw-file-hint">${W.choose_source}</span>
|
|
170
|
+
</div>
|
|
171
|
+
<div class="cw-sources">${cardsHtml}</div>
|
|
172
|
+
</div>`;
|
|
173
|
+
|
|
174
|
+
container.querySelectorAll('.cw-source-card').forEach(btn => {
|
|
175
|
+
btn.addEventListener('click', () => wizardSelectSource(state, btn.dataset.source, sessionId));
|
|
176
|
+
});
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
async function wizardSelectSource(state, sourceId, sessionId) {
|
|
180
|
+
state.wizardStep = sourceId;
|
|
181
|
+
state.wizardFocusIndex = -1;
|
|
182
|
+
state.wizardPickerData = [];
|
|
183
|
+
|
|
184
|
+
// For working changes, render immediately (no data to fetch)
|
|
185
|
+
if (sourceId === 'working') {
|
|
186
|
+
renderWizard(sessionId);
|
|
187
|
+
return;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// Show loading
|
|
191
|
+
const container = state.currentContainer;
|
|
192
|
+
if (container) {
|
|
193
|
+
container.innerHTML = `<div class="cw-wizard"><div class="cw-loading">${ICONS.loading} ${S.widgets.diff_viewer.wizard.loading_commits}</div></div>`;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
const cwd = state.wizardCwd || WidgetManager.currentCwd || '';
|
|
197
|
+
const filePath = state.wizardFilePath;
|
|
198
|
+
// Relative path for API calls
|
|
199
|
+
const relPath = relativeTo(filePath, cwd);
|
|
200
|
+
|
|
201
|
+
try {
|
|
202
|
+
if (sourceId === 'shadow') {
|
|
203
|
+
const resp = await fetch(`${CONFIG.API_BASE}/api/shadow/files/${encodeURIComponent(relPath)}/history?cwd=${encodeURIComponent(cwd)}&limit=50`);
|
|
204
|
+
if (resp.ok) {
|
|
205
|
+
const data = await resp.json();
|
|
206
|
+
state.wizardPickerData = data.commits || [];
|
|
207
|
+
}
|
|
208
|
+
} else if (sourceId === 'git') {
|
|
209
|
+
const resp = await fetch(`${CONFIG.API_BASE}/api/git/file-log?file=${encodeURIComponent(relPath)}&cwd=${encodeURIComponent(cwd)}&limit=30`);
|
|
210
|
+
if (resp.ok) {
|
|
211
|
+
const data = await resp.json();
|
|
212
|
+
state.wizardPickerData = data.commits || [];
|
|
213
|
+
}
|
|
214
|
+
} else if (sourceId === 'branch') {
|
|
215
|
+
const resp = await fetch(`${CONFIG.API_BASE}/api/git/branches?cwd=${encodeURIComponent(cwd)}`);
|
|
216
|
+
if (resp.ok) {
|
|
217
|
+
const data = await resp.json();
|
|
218
|
+
state.wizardPickerData = (data.branches || []).filter(b => !b.isCurrent);
|
|
219
|
+
}
|
|
220
|
+
} else if (sourceId === 'file') {
|
|
221
|
+
if (!state.wizardAllFiles) {
|
|
222
|
+
const resp = await fetch(`${CONFIG.API_BASE}/api/files/list?cwd=${encodeURIComponent(cwd)}`);
|
|
223
|
+
if (resp.ok) {
|
|
224
|
+
const data = await resp.json();
|
|
225
|
+
state.wizardAllFiles = (data.files || []).filter(f => f !== relPath);
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
state.wizardPickerData = state.wizardAllFiles || [];
|
|
229
|
+
}
|
|
230
|
+
} catch (err) {
|
|
231
|
+
console.error('[DiffViewer] Wizard fetch error:', err);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
renderWizard(sessionId);
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
function renderWizardPicker(state, container, sessionId) {
|
|
238
|
+
const W = S.widgets.diff_viewer.wizard;
|
|
239
|
+
const fileName = basename(state.wizardFilePath);
|
|
240
|
+
const step = state.wizardStep;
|
|
241
|
+
|
|
242
|
+
// Build picker header with back button
|
|
243
|
+
const backHtml = `
|
|
244
|
+
<div class="cw-picker-header">
|
|
245
|
+
<button class="cw-back-btn" data-action="wizard-back">${ICONS.back} ${W.back}</button>
|
|
246
|
+
<span class="cw-picker-title">${getPickerTitle(step, fileName)}</span>
|
|
247
|
+
</div>`;
|
|
248
|
+
|
|
249
|
+
let bodyHtml = '';
|
|
250
|
+
|
|
251
|
+
if (step === 'working') {
|
|
252
|
+
bodyHtml = renderWorkingPicker(state);
|
|
253
|
+
} else if (step === 'file') {
|
|
254
|
+
bodyHtml = renderFilePicker(state);
|
|
255
|
+
} else {
|
|
256
|
+
bodyHtml = renderCommitList(state, step);
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
container.innerHTML = `<div class="cw-wizard">${backHtml}${bodyHtml}</div>`;
|
|
260
|
+
|
|
261
|
+
// Wire up back button
|
|
262
|
+
container.querySelector('[data-action="wizard-back"]')?.addEventListener('click', () => {
|
|
263
|
+
state.wizardStep = 'source';
|
|
264
|
+
state.wizardFocusIndex = -1;
|
|
265
|
+
renderWizard(sessionId);
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
// Wire up commit/branch clicks
|
|
269
|
+
container.querySelectorAll('.cw-item').forEach(el => {
|
|
270
|
+
el.addEventListener('click', () => {
|
|
271
|
+
wizardPickItem(state, sessionId, el.dataset);
|
|
272
|
+
});
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
// Wire up working change buttons
|
|
276
|
+
container.querySelectorAll('.cw-working-btn').forEach(btn => {
|
|
277
|
+
btn.addEventListener('click', () => {
|
|
278
|
+
wizardPickWorking(state, sessionId, btn.dataset.staged === 'true');
|
|
279
|
+
});
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
// Wire up file picker search
|
|
283
|
+
const searchInput = container.querySelector('.cw-file-search');
|
|
284
|
+
if (searchInput) {
|
|
285
|
+
searchInput.value = state.wizardFileQuery;
|
|
286
|
+
searchInput.addEventListener('input', (e) => {
|
|
287
|
+
state.wizardFileQuery = e.target.value;
|
|
288
|
+
// Re-render just the file list
|
|
289
|
+
const listEl = container.querySelector('.cw-list');
|
|
290
|
+
if (listEl) listEl.innerHTML = renderFileListItems(state);
|
|
291
|
+
// Re-wire file clicks
|
|
292
|
+
container.querySelectorAll('.cw-file-item').forEach(el => {
|
|
293
|
+
el.addEventListener('click', () => {
|
|
294
|
+
wizardPickFile(state, sessionId, el.dataset.path);
|
|
295
|
+
});
|
|
296
|
+
});
|
|
297
|
+
});
|
|
298
|
+
searchInput.focus();
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
// Wire up file item clicks
|
|
302
|
+
container.querySelectorAll('.cw-file-item').forEach(el => {
|
|
303
|
+
el.addEventListener('click', () => {
|
|
304
|
+
wizardPickFile(state, sessionId, el.dataset.path);
|
|
305
|
+
});
|
|
306
|
+
});
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
function getPickerTitle(step, fileName) {
|
|
310
|
+
const W = S.widgets.diff_viewer.wizard;
|
|
311
|
+
switch (step) {
|
|
312
|
+
case 'shadow': return W.picker_shadow.replace('{file}', fileName);
|
|
313
|
+
case 'git': return W.picker_git.replace('{file}', fileName);
|
|
314
|
+
case 'branch': return W.picker_branch;
|
|
315
|
+
case 'file': return W.picker_file;
|
|
316
|
+
case 'working': return W.source_working;
|
|
317
|
+
default: return '';
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
function renderCommitList(state, step) {
|
|
322
|
+
const W = S.widgets.diff_viewer.wizard;
|
|
323
|
+
const items = state.wizardPickerData;
|
|
324
|
+
|
|
325
|
+
if (!items.length) {
|
|
326
|
+
const emptyMsg = step === 'shadow' ? W.no_shadow_history :
|
|
327
|
+
step === 'branch' ? W.no_branches : W.no_git_history;
|
|
328
|
+
return `<div class="cw-empty">${emptyMsg}</div>`;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
let html = '<div class="cw-list">';
|
|
332
|
+
items.forEach((item, i) => {
|
|
333
|
+
const focused = i === state.wizardFocusIndex ? ' cw-focused' : '';
|
|
334
|
+
const time = item.timestamp ? timeAgo(item.timestamp) : '';
|
|
335
|
+
|
|
336
|
+
if (step === 'branch') {
|
|
337
|
+
html += `<div class="cw-item${focused}" data-ref="${escapeHtml(item.name)}" data-idx="${i}">
|
|
338
|
+
<span class="cw-item-hash">${escapeHtml(item.name)}</span>
|
|
339
|
+
<span class="cw-item-subject">${escapeHtml(item.subject || '')}</span>
|
|
340
|
+
<span class="cw-item-time">${time}</span>
|
|
341
|
+
</div>`;
|
|
342
|
+
} else {
|
|
343
|
+
const hash = item.hashShort || item.hash?.slice(0, 8) || '';
|
|
344
|
+
const ref = item.hashFull || item.hash || '';
|
|
345
|
+
const statsHtml = (item.additions || item.deletions) ? `
|
|
346
|
+
<span class="cw-item-stats">
|
|
347
|
+
${item.additions ? `<span class="added">+${item.additions}</span>` : ''}
|
|
348
|
+
${item.deletions ? `<span class="removed">-${item.deletions}</span>` : ''}
|
|
349
|
+
</span>` : '';
|
|
350
|
+
|
|
351
|
+
html += `<div class="cw-item${focused}" data-ref="${escapeHtml(ref)}" data-idx="${i}">
|
|
352
|
+
<span class="cw-item-hash">${escapeHtml(hash)}</span>
|
|
353
|
+
<span class="cw-item-subject">${escapeHtml(item.subject || '')}</span>
|
|
354
|
+
${statsHtml}
|
|
355
|
+
${item.author ? `<span class="cw-item-author">${escapeHtml(item.author)}</span>` : ''}
|
|
356
|
+
<span class="cw-item-time">${time}</span>
|
|
357
|
+
</div>`;
|
|
358
|
+
}
|
|
359
|
+
});
|
|
360
|
+
html += '</div>';
|
|
361
|
+
return html;
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
function renderWorkingPicker(state) {
|
|
365
|
+
const W = S.widgets.diff_viewer.wizard;
|
|
366
|
+
return `
|
|
367
|
+
<div class="cw-working-options">
|
|
368
|
+
<button class="cw-working-btn" data-staged="false">
|
|
369
|
+
${ICONS.edit}
|
|
370
|
+
<span>${W.working_tree}</span>
|
|
371
|
+
</button>
|
|
372
|
+
<button class="cw-working-btn" data-staged="true">
|
|
373
|
+
${ICONS.check}
|
|
374
|
+
<span>${W.staged_changes}</span>
|
|
375
|
+
</button>
|
|
376
|
+
</div>`;
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
function renderFilePicker(state) {
|
|
380
|
+
const W = S.widgets.diff_viewer.wizard;
|
|
381
|
+
return `
|
|
382
|
+
<input class="cw-file-search" type="text" placeholder="${W.search_placeholder}" autocomplete="off" spellcheck="false">
|
|
383
|
+
<div class="cw-list">${renderFileListItems(state)}</div>`;
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
function renderFileListItems(state) {
|
|
387
|
+
const query = state.wizardFileQuery.toLowerCase();
|
|
388
|
+
const files = state.wizardPickerData;
|
|
389
|
+
const filtered = query ? files.filter(f => f.toLowerCase().includes(query)) : files;
|
|
390
|
+
const shown = filtered.slice(0, 200); // limit for performance
|
|
391
|
+
|
|
392
|
+
if (!shown.length) return `<div class="cw-empty">${S.widgets.diff_viewer.wizard.no_git_history}</div>`;
|
|
393
|
+
|
|
394
|
+
let html = '';
|
|
395
|
+
shown.forEach((filePath, i) => {
|
|
396
|
+
const name = basename(filePath);
|
|
397
|
+
const dir = filePath.includes('/') ? filePath.slice(0, filePath.lastIndexOf('/') + 1) : '';
|
|
398
|
+
const focused = i === state.wizardFocusIndex ? ' cw-focused' : '';
|
|
399
|
+
html += `<div class="cw-file-item${focused}" data-path="${escapeHtml(filePath)}" data-idx="${i}">
|
|
400
|
+
<span class="cw-file-item-icon">${ICONS.file}</span>
|
|
401
|
+
<span class="cw-file-item-name">${escapeHtml(name)}</span>
|
|
402
|
+
<span class="cw-file-item-dir">${escapeHtml(dir)}</span>
|
|
403
|
+
</div>`;
|
|
404
|
+
});
|
|
405
|
+
return html;
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
// ── Wizard actions ──
|
|
409
|
+
|
|
410
|
+
async function wizardPickItem(state, sessionId, dataset) {
|
|
411
|
+
const ref = dataset.ref;
|
|
412
|
+
if (!ref) return;
|
|
413
|
+
|
|
414
|
+
const cwd = state.wizardCwd || WidgetManager.currentCwd || '';
|
|
415
|
+
const filePath = state.wizardFilePath;
|
|
416
|
+
const relPath = relativeTo(filePath, cwd);
|
|
417
|
+
const step = state.wizardStep;
|
|
418
|
+
|
|
419
|
+
// Show loading in the diff viewer
|
|
420
|
+
state.wizardActive = false;
|
|
421
|
+
state.loading = true;
|
|
422
|
+
state.files = [];
|
|
423
|
+
renderContent(sessionId);
|
|
424
|
+
|
|
425
|
+
try {
|
|
426
|
+
let refContent;
|
|
427
|
+
if (step === 'shadow') {
|
|
428
|
+
const resp = await fetch(`${CONFIG.API_BASE}/api/shadow/files/${encodeURIComponent(relPath)}/content?ref=${encodeURIComponent(ref)}&cwd=${encodeURIComponent(cwd)}`);
|
|
429
|
+
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
|
|
430
|
+
const data = await resp.json();
|
|
431
|
+
refContent = data.content;
|
|
432
|
+
} else if (step === 'git' || step === 'branch') {
|
|
433
|
+
const resp = await fetch(`${CONFIG.API_BASE}/api/git/file-at-ref?file=${encodeURIComponent(relPath)}&ref=${encodeURIComponent(ref)}&cwd=${encodeURIComponent(cwd)}`);
|
|
434
|
+
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
|
|
435
|
+
const data = await resp.json();
|
|
436
|
+
refContent = data.content;
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
if (refContent !== undefined) {
|
|
440
|
+
const currentContent = state.wizardFileContent || '';
|
|
441
|
+
const refLabel = step === 'branch' ? ref : ref.slice(0, 8);
|
|
442
|
+
state.loading = false;
|
|
443
|
+
state.files = [{
|
|
444
|
+
path: relPath,
|
|
445
|
+
oldContent: refContent,
|
|
446
|
+
newContent: currentContent,
|
|
447
|
+
oldLabel: refLabel,
|
|
448
|
+
newLabel: 'Current',
|
|
449
|
+
startLine: 1,
|
|
450
|
+
additions: 0,
|
|
451
|
+
deletions: 0
|
|
452
|
+
}];
|
|
453
|
+
state.source = 'wizard';
|
|
454
|
+
state.viewMode = loadViewMode() || ((state.currentContainer?.offsetWidth >= 600) ? 'split' : 'unified');
|
|
455
|
+
state.currentFileIndex = 0;
|
|
456
|
+
state.currentHunkIndex = -1;
|
|
457
|
+
renderContent(sessionId);
|
|
458
|
+
}
|
|
459
|
+
} catch (err) {
|
|
460
|
+
console.error('[DiffViewer] Wizard pick error:', err);
|
|
461
|
+
state.loading = false;
|
|
462
|
+
state.error = err.message;
|
|
463
|
+
renderContent(sessionId);
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
async function wizardPickWorking(state, sessionId, staged) {
|
|
468
|
+
const cwd = state.wizardCwd || WidgetManager.currentCwd || '';
|
|
469
|
+
const filePath = state.wizardFilePath;
|
|
470
|
+
const relPath = relativeTo(filePath, cwd);
|
|
471
|
+
|
|
472
|
+
state.wizardActive = false;
|
|
473
|
+
state.loading = true;
|
|
474
|
+
state.files = [];
|
|
475
|
+
renderContent(sessionId);
|
|
476
|
+
|
|
477
|
+
try {
|
|
478
|
+
const resp = await fetch(`${CONFIG.API_BASE}/api/git/file-content?file=${encodeURIComponent(relPath)}&cwd=${encodeURIComponent(cwd)}&staged=${staged}`);
|
|
479
|
+
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
|
|
480
|
+
const data = await resp.json();
|
|
481
|
+
|
|
482
|
+
state.loading = false;
|
|
483
|
+
state.files = [{
|
|
484
|
+
path: relPath,
|
|
485
|
+
oldContent: data.old || '',
|
|
486
|
+
newContent: data.new || '',
|
|
487
|
+
oldLabel: data.oldLabel || 'HEAD',
|
|
488
|
+
newLabel: data.newLabel || (staged ? 'Staged' : 'Working Tree'),
|
|
489
|
+
startLine: 1,
|
|
490
|
+
additions: 0,
|
|
491
|
+
deletions: 0
|
|
492
|
+
}];
|
|
493
|
+
state.source = 'wizard';
|
|
494
|
+
state.viewMode = loadViewMode() || ((state.currentContainer?.offsetWidth >= 600) ? 'split' : 'unified');
|
|
495
|
+
state.currentFileIndex = 0;
|
|
496
|
+
state.currentHunkIndex = -1;
|
|
497
|
+
renderContent(sessionId);
|
|
498
|
+
} catch (err) {
|
|
499
|
+
console.error('[DiffViewer] Working changes error:', err);
|
|
500
|
+
state.loading = false;
|
|
501
|
+
state.error = err.message;
|
|
502
|
+
renderContent(sessionId);
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
async function wizardPickFile(state, sessionId, otherPath) {
|
|
507
|
+
const cwd = state.wizardCwd || WidgetManager.currentCwd || '';
|
|
508
|
+
|
|
509
|
+
state.wizardActive = false;
|
|
510
|
+
state.loading = true;
|
|
511
|
+
state.files = [];
|
|
512
|
+
renderContent(sessionId);
|
|
513
|
+
|
|
514
|
+
try {
|
|
515
|
+
const fullOtherPath = isAbsolutePath(otherPath) ? otherPath : joinPath(cwd, otherPath);
|
|
516
|
+
const resp = await fetch(`${CONFIG.API_BASE}/api/file?path=${encodeURIComponent(fullOtherPath)}&cwd=${encodeURIComponent(cwd)}`);
|
|
517
|
+
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
|
|
518
|
+
const data = await resp.json();
|
|
519
|
+
|
|
520
|
+
const currentContent = state.wizardFileContent || '';
|
|
521
|
+
state.loading = false;
|
|
522
|
+
state.files = [{
|
|
523
|
+
path: state.wizardFilePath,
|
|
524
|
+
oldContent: currentContent,
|
|
525
|
+
newContent: data.content || '',
|
|
526
|
+
oldLabel: basename(state.wizardFilePath),
|
|
527
|
+
newLabel: basename(otherPath),
|
|
528
|
+
startLine: 1,
|
|
529
|
+
additions: 0,
|
|
530
|
+
deletions: 0
|
|
531
|
+
}];
|
|
532
|
+
state.source = 'wizard';
|
|
533
|
+
state.viewMode = loadViewMode() || ((state.currentContainer?.offsetWidth >= 600) ? 'split' : 'unified');
|
|
534
|
+
state.currentFileIndex = 0;
|
|
535
|
+
state.currentHunkIndex = -1;
|
|
536
|
+
renderContent(sessionId);
|
|
537
|
+
} catch (err) {
|
|
538
|
+
console.error('[DiffViewer] File compare error:', err);
|
|
539
|
+
state.loading = false;
|
|
540
|
+
state.error = err.message;
|
|
541
|
+
renderContent(sessionId);
|
|
542
|
+
}
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
// ═══════════════════════════════════════════════════════════════
|
|
546
|
+
// Quick Compare (one-click presets, bypasses wizard)
|
|
547
|
+
// ═══════════════════════════════════════════════════════════════
|
|
548
|
+
|
|
549
|
+
function _relPath(filePath, cwd) {
|
|
550
|
+
return relativeTo(filePath, cwd);
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
async function _shadowContent(relPath, ref, cwd) {
|
|
554
|
+
const url = `${CONFIG.API_BASE}/api/shadow/files/${encodeURIComponent(relPath)}/content?ref=${encodeURIComponent(ref)}&cwd=${encodeURIComponent(cwd)}`;
|
|
555
|
+
const resp = await fetch(url);
|
|
556
|
+
if (!resp.ok) return null;
|
|
557
|
+
return (await resp.json()).content;
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
async function _gitContentAtRef(relPath, ref, cwd) {
|
|
561
|
+
const url = `${CONFIG.API_BASE}/api/git/file-at-ref?file=${encodeURIComponent(relPath)}&ref=${encodeURIComponent(ref)}&cwd=${encodeURIComponent(cwd)}`;
|
|
562
|
+
const resp = await fetch(url);
|
|
563
|
+
if (!resp.ok) return null;
|
|
564
|
+
return (await resp.json()).content;
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
async function _currentFileContent(filePath, cwd) {
|
|
568
|
+
const url = `${CONFIG.API_BASE}/api/file?path=${encodeURIComponent(filePath)}&cwd=${encodeURIComponent(cwd)}`;
|
|
569
|
+
const resp = await fetch(url);
|
|
570
|
+
if (!resp.ok) return null;
|
|
571
|
+
return (await resp.json()).content;
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
async function quickCompareGitHead(filePath, cwd) {
|
|
575
|
+
cwd = cwd || WidgetManager.currentCwd || '';
|
|
576
|
+
const relPath = _relPath(filePath, cwd);
|
|
577
|
+
try {
|
|
578
|
+
const resp = await fetch(`${CONFIG.API_BASE}/api/git/file-content?file=${encodeURIComponent(relPath)}&cwd=${encodeURIComponent(cwd)}`);
|
|
579
|
+
if (!resp.ok) throw new Error('Git read failed');
|
|
580
|
+
const data = await resp.json();
|
|
581
|
+
DiffViewerWidget.openWithContent(filePath, data.old || '', data.new || '', {
|
|
582
|
+
source: 'quick-compare',
|
|
583
|
+
oldLabel: data.oldLabel || 'HEAD',
|
|
584
|
+
newLabel: data.newLabel || 'Working Tree',
|
|
585
|
+
});
|
|
586
|
+
} catch (err) {
|
|
587
|
+
console.error('[DiffViewer] quickCompareGitHead failed:', err);
|
|
588
|
+
showToast(`${S.toast.compare_failed}: ${err.message}`);
|
|
589
|
+
}
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
async function quickComparePreviousCommit(filePath, cwd) {
|
|
593
|
+
cwd = cwd || WidgetManager.currentCwd || '';
|
|
594
|
+
const relPath = _relPath(filePath, cwd);
|
|
595
|
+
try {
|
|
596
|
+
const [oldContent, newContent] = await Promise.all([
|
|
597
|
+
_gitContentAtRef(relPath, 'HEAD~1', cwd),
|
|
598
|
+
_gitContentAtRef(relPath, 'HEAD', cwd),
|
|
599
|
+
]);
|
|
600
|
+
if (oldContent === null && newContent === null) {
|
|
601
|
+
showToast(S.toast.compare_no_git_history);
|
|
602
|
+
return;
|
|
603
|
+
}
|
|
604
|
+
DiffViewerWidget.openWithContent(filePath, oldContent || '', newContent || '', {
|
|
605
|
+
source: 'quick-compare',
|
|
606
|
+
oldLabel: 'HEAD~1',
|
|
607
|
+
newLabel: 'HEAD',
|
|
608
|
+
});
|
|
609
|
+
} catch (err) {
|
|
610
|
+
console.error('[DiffViewer] quickComparePreviousCommit failed:', err);
|
|
611
|
+
showToast(`${S.toast.compare_failed}: ${err.message}`);
|
|
612
|
+
}
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
// ═══════════════════════════════════════════════════════════════
|
|
616
|
+
// History Bar (stepper + picker + pivots)
|
|
617
|
+
// ═══════════════════════════════════════════════════════════════
|
|
618
|
+
|
|
619
|
+
function _stateCwd(state) {
|
|
620
|
+
return state.wizardCwd || WidgetManager.currentCwd || '';
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
async function loadHistoryForCurrentFile(state, sessionId) {
|
|
624
|
+
if (!state.files.length) return;
|
|
625
|
+
const file = state.files[state.currentFileIndex];
|
|
626
|
+
if (!file?.path || file.path === 'untitled') return;
|
|
627
|
+
|
|
628
|
+
const cwd = _stateCwd(state);
|
|
629
|
+
const key = `${cwd}:${file.path}`;
|
|
630
|
+
if (state.historyKey === key && state.historyCommits !== null) return;
|
|
631
|
+
|
|
632
|
+
state.historyKey = key;
|
|
633
|
+
state.historyLoading = true;
|
|
634
|
+
state.historyCommits = null;
|
|
635
|
+
|
|
636
|
+
const relPath = _relPath(file.path, cwd);
|
|
637
|
+
try {
|
|
638
|
+
const url = `${CONFIG.API_BASE}/api/shadow/files/${encodeURIComponent(relPath)}/history?cwd=${encodeURIComponent(cwd)}&limit=20`;
|
|
639
|
+
const resp = await fetch(url);
|
|
640
|
+
if (resp.ok) {
|
|
641
|
+
const data = await resp.json();
|
|
642
|
+
state.historyCommits = data.commits || [];
|
|
643
|
+
} else {
|
|
644
|
+
state.historyCommits = [];
|
|
645
|
+
}
|
|
646
|
+
} catch {
|
|
647
|
+
state.historyCommits = [];
|
|
648
|
+
}
|
|
649
|
+
state.historyLoading = false;
|
|
650
|
+
|
|
651
|
+
// Try to align historyIndex to current new-side label (a hash prefix match)
|
|
652
|
+
state.historyIndex = findHistoryIndex(state, file);
|
|
653
|
+
|
|
654
|
+
// Re-render bar only (cheaper than full content rerender)
|
|
655
|
+
renderHistoryBar(state, sessionId);
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
function findHistoryIndex(state, file) {
|
|
659
|
+
if (!state.historyCommits?.length) return -1;
|
|
660
|
+
const label = (file.newLabel || '').trim();
|
|
661
|
+
if (!label) return -1;
|
|
662
|
+
// Match on hashShort prefix
|
|
663
|
+
return state.historyCommits.findIndex(c =>
|
|
664
|
+
c.hash && (label === c.hash || label.startsWith(c.hash) || c.hash.startsWith(label))
|
|
665
|
+
);
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
function renderHistoryBar(state, sessionId) {
|
|
669
|
+
const container = state.currentContainer;
|
|
670
|
+
if (!container) return;
|
|
671
|
+
const barEl = container.querySelector('.dv-history-bar');
|
|
672
|
+
if (!barEl) return;
|
|
673
|
+
|
|
674
|
+
barEl.innerHTML = buildHistoryBarHtml(state);
|
|
675
|
+
wireHistoryBar(state, sessionId, barEl);
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
function buildHistoryBarHtml(state) {
|
|
679
|
+
const HB = S.widgets.diff_viewer.history_bar;
|
|
680
|
+
const commits = state.historyCommits;
|
|
681
|
+
const i = state.historyIndex;
|
|
682
|
+
|
|
683
|
+
const hasHistory = Array.isArray(commits) && commits.length > 0;
|
|
684
|
+
const atIndex = hasHistory && i >= 0 && i < commits.length;
|
|
685
|
+
|
|
686
|
+
const prevDisabled = !hasHistory || (i >= commits.length - 1);
|
|
687
|
+
const nextDisabled = !hasHistory || i <= 0;
|
|
688
|
+
|
|
689
|
+
let pickerLabel;
|
|
690
|
+
let pickerSub = '';
|
|
691
|
+
if (state.historyLoading) {
|
|
692
|
+
pickerLabel = HB.picker_loading;
|
|
693
|
+
} else if (!hasHistory) {
|
|
694
|
+
pickerLabel = HB.picker_empty;
|
|
695
|
+
} else if (atIndex) {
|
|
696
|
+
const c = commits[i];
|
|
697
|
+
pickerLabel = `${c.hash} · ${c.summary || c.subject || ''}`;
|
|
698
|
+
pickerSub = `${c.additions ? '+' + c.additions : ''}${c.deletions ? ' -' + c.deletions : ''}${c.timestamp ? ' · ' + timeAgo(c.timestamp) : ''}`.trim();
|
|
699
|
+
} else {
|
|
700
|
+
pickerLabel = HB.picker_no_match;
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
const dis = (d) => d ? ' dv-disabled' : '';
|
|
704
|
+
return `
|
|
705
|
+
<div class="dv-stepper">
|
|
706
|
+
<button class="dv-step-btn${dis(prevDisabled)}" data-action="step-prev" data-tooltip="${HB.step_prev}" ${prevDisabled ? 'disabled' : ''}>${ICONS.back}</button>
|
|
707
|
+
<button class="dv-picker" data-action="picker">
|
|
708
|
+
<span class="dv-picker-label">${escapeHtml(pickerLabel)}</span>
|
|
709
|
+
${pickerSub ? `<span class="dv-picker-sub">${escapeHtml(pickerSub)}</span>` : ''}
|
|
710
|
+
<span class="dv-picker-caret">${ICONS.down}</span>
|
|
711
|
+
</button>
|
|
712
|
+
<button class="dv-step-btn${dis(nextDisabled)}" data-action="step-next" data-tooltip="${HB.step_next}" ${nextDisabled ? 'disabled' : ''}>${ICONS.forward}</button>
|
|
713
|
+
</div>
|
|
714
|
+
<div class="dv-divider"></div>
|
|
715
|
+
<div class="dv-pivots">
|
|
716
|
+
<button class="dv-pivot" data-action="pivot-head" data-tooltip="${HB.pivot_head_tooltip}">${HB.pivot_head}</button>
|
|
717
|
+
<button class="dv-pivot" data-action="pivot-working" data-tooltip="${HB.pivot_working_tooltip}">${HB.pivot_working}</button>
|
|
718
|
+
<button class="dv-pivot" data-action="pivot-pick" data-tooltip="${HB.pivot_pick_tooltip}">${HB.pivot_pick}</button>
|
|
719
|
+
</div>`;
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
function wireHistoryBar(state, sessionId, barEl) {
|
|
723
|
+
barEl.querySelector('[data-action="step-prev"]')?.addEventListener('click', (e) => {
|
|
724
|
+
if (e.currentTarget.disabled) return;
|
|
725
|
+
stepHistory(state, sessionId, +1);
|
|
726
|
+
});
|
|
727
|
+
barEl.querySelector('[data-action="step-next"]')?.addEventListener('click', (e) => {
|
|
728
|
+
if (e.currentTarget.disabled) return;
|
|
729
|
+
stepHistory(state, sessionId, -1);
|
|
730
|
+
});
|
|
731
|
+
barEl.querySelector('[data-action="picker"]')?.addEventListener('click', (e) => {
|
|
732
|
+
showHistoryPicker(state, sessionId, e.currentTarget);
|
|
733
|
+
});
|
|
734
|
+
barEl.querySelector('[data-action="pivot-head"]')?.addEventListener('click', () => {
|
|
735
|
+
pivotVsHead(state);
|
|
736
|
+
});
|
|
737
|
+
barEl.querySelector('[data-action="pivot-working"]')?.addEventListener('click', () => {
|
|
738
|
+
pivotVsWorking(state);
|
|
739
|
+
});
|
|
740
|
+
barEl.querySelector('[data-action="pivot-pick"]')?.addEventListener('click', () => {
|
|
741
|
+
pivotPick(state);
|
|
742
|
+
});
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
function stepHistory(state, sessionId, delta) {
|
|
746
|
+
if (!state.historyCommits?.length) return;
|
|
747
|
+
let target = state.historyIndex;
|
|
748
|
+
if (target < 0) {
|
|
749
|
+
// Not aligned yet — first step jumps to newest (index 0) when going newer,
|
|
750
|
+
// or to oldest visible window (index 0) when going older. Default to newest.
|
|
751
|
+
target = delta > 0 ? 0 : 0;
|
|
752
|
+
} else {
|
|
753
|
+
target = target + delta;
|
|
754
|
+
}
|
|
755
|
+
if (target < 0 || target >= state.historyCommits.length) return;
|
|
756
|
+
state.historyIndex = target;
|
|
757
|
+
loadDiffAtHistoryIndex(state, sessionId);
|
|
758
|
+
}
|
|
759
|
+
|
|
760
|
+
function showHistoryPicker(state, sessionId, anchor) {
|
|
761
|
+
if (!state.historyCommits?.length) return;
|
|
762
|
+
const HB = S.widgets.diff_viewer.history_bar;
|
|
763
|
+
const rect = anchor.getBoundingClientRect();
|
|
764
|
+
|
|
765
|
+
const items = [];
|
|
766
|
+
let lastSession = null;
|
|
767
|
+
state.historyCommits.forEach((c, i) => {
|
|
768
|
+
if (lastSession !== null && c.sessionId && c.sessionId !== lastSession) {
|
|
769
|
+
items.push({ type: 'separator' });
|
|
770
|
+
}
|
|
771
|
+
if (c.sessionId) lastSession = c.sessionId;
|
|
772
|
+
|
|
773
|
+
const stats = `${c.additions ? '+' + c.additions : ''}${c.deletions ? ' -' + c.deletions : ''}`.trim();
|
|
774
|
+
const time = c.timestamp ? timeAgo(c.timestamp) : '';
|
|
775
|
+
const sub = [stats, time].filter(Boolean).join(' · ');
|
|
776
|
+
|
|
777
|
+
items.push({
|
|
778
|
+
label: `${c.hash} · ${(c.summary || c.subject || '').slice(0, 80)}`,
|
|
779
|
+
sublabel: sub,
|
|
780
|
+
action: () => {
|
|
781
|
+
state.historyIndex = i;
|
|
782
|
+
loadDiffAtHistoryIndex(state, sessionId);
|
|
783
|
+
}
|
|
784
|
+
});
|
|
785
|
+
});
|
|
786
|
+
|
|
787
|
+
const menu = window.app?.contextMenu || (window._dvCtxMenu ||= new ContextMenu());
|
|
788
|
+
menu.show(rect.left, rect.bottom + 4, items);
|
|
789
|
+
}
|
|
790
|
+
|
|
791
|
+
async function loadDiffAtHistoryIndex(state, sessionId) {
|
|
792
|
+
const commits = state.historyCommits;
|
|
793
|
+
const i = state.historyIndex;
|
|
794
|
+
if (!commits || i < 0 || i >= commits.length) return;
|
|
795
|
+
|
|
796
|
+
const file = state.files[state.currentFileIndex];
|
|
797
|
+
if (!file) return;
|
|
798
|
+
|
|
799
|
+
const cwd = _stateCwd(state);
|
|
800
|
+
const relPath = _relPath(file.path, cwd);
|
|
801
|
+
const newCommit = commits[i];
|
|
802
|
+
const oldCommit = commits[i + 1]; // next-older on the linear file history
|
|
803
|
+
|
|
804
|
+
state.loading = true;
|
|
805
|
+
renderContent(sessionId);
|
|
806
|
+
|
|
807
|
+
try {
|
|
808
|
+
const newContent = await _shadowContent(relPath, newCommit.hashFull, cwd);
|
|
809
|
+
const oldContent = oldCommit
|
|
810
|
+
? await _shadowContent(relPath, oldCommit.hashFull, cwd)
|
|
811
|
+
: await _shadowContent(relPath, newCommit.hashFull + '~1', cwd);
|
|
812
|
+
|
|
813
|
+
const HB = S.widgets.diff_viewer.history_bar;
|
|
814
|
+
state.loading = false;
|
|
815
|
+
state.files = [{
|
|
816
|
+
path: file.path,
|
|
817
|
+
oldContent: oldContent || '',
|
|
818
|
+
newContent: newContent || '',
|
|
819
|
+
oldLabel: oldCommit ? oldCommit.hash : HB.picker_initial,
|
|
820
|
+
newLabel: newCommit.hash,
|
|
821
|
+
startLine: 1,
|
|
822
|
+
additions: 0,
|
|
823
|
+
deletions: 0
|
|
824
|
+
}];
|
|
825
|
+
state.source = 'history-bar';
|
|
826
|
+
state.currentFileIndex = 0;
|
|
827
|
+
state.currentHunkIndex = -1;
|
|
828
|
+
renderContent(sessionId);
|
|
829
|
+
} catch (err) {
|
|
830
|
+
console.error('[DiffViewer] loadDiffAtHistoryIndex failed:', err);
|
|
831
|
+
state.loading = false;
|
|
832
|
+
state.error = err.message;
|
|
833
|
+
renderContent(sessionId);
|
|
834
|
+
}
|
|
835
|
+
}
|
|
836
|
+
|
|
837
|
+
async function pivotVsHead(state) {
|
|
838
|
+
const file = state.files[state.currentFileIndex];
|
|
839
|
+
if (!file?.path) return;
|
|
840
|
+
await quickCompareGitHead(file.path, _stateCwd(state));
|
|
841
|
+
}
|
|
842
|
+
|
|
843
|
+
async function pivotVsWorking(state) {
|
|
844
|
+
const file = state.files[state.currentFileIndex];
|
|
845
|
+
if (!file?.path) return;
|
|
846
|
+
const cwd = _stateCwd(state);
|
|
847
|
+
try {
|
|
848
|
+
const workingContent = await _currentFileContent(file.path, cwd);
|
|
849
|
+
if (workingContent === null) {
|
|
850
|
+
showToast(S.toast.compare_failed);
|
|
851
|
+
return;
|
|
852
|
+
}
|
|
853
|
+
DiffViewerWidget.openWithContent(file.path, file.newContent || '', workingContent, {
|
|
854
|
+
source: 'history-bar',
|
|
855
|
+
oldLabel: file.newLabel || S.widgets.diff_viewer.before,
|
|
856
|
+
newLabel: 'Working Tree'
|
|
857
|
+
});
|
|
858
|
+
} catch (err) {
|
|
859
|
+
console.error('[DiffViewer] pivotVsWorking failed:', err);
|
|
860
|
+
showToast(`${S.toast.compare_failed}: ${err.message}`);
|
|
861
|
+
}
|
|
862
|
+
}
|
|
863
|
+
|
|
864
|
+
function pivotPick(state) {
|
|
865
|
+
const file = state.files[state.currentFileIndex];
|
|
866
|
+
if (!file?.path) return;
|
|
867
|
+
DiffViewerWidget.openCompareWizard(file.path, _stateCwd(state));
|
|
868
|
+
}
|
|
869
|
+
|
|
870
|
+
function shouldShowHistoryBar(state) {
|
|
871
|
+
if (state.wizardActive) return false;
|
|
872
|
+
if (state.loading || state.error) return false;
|
|
873
|
+
if (state.files.length !== 1) return false;
|
|
874
|
+
const f = state.files[0];
|
|
875
|
+
if (!f?.path || f.path === 'untitled') return false;
|
|
876
|
+
return true;
|
|
877
|
+
}
|
|
878
|
+
|
|
879
|
+
// ═══════════════════════════════════════════════════════════════
|
|
880
|
+
// Rendering
|
|
881
|
+
// ═══════════════════════════════════════════════════════════════
|
|
882
|
+
|
|
883
|
+
function renderContent(sessionId) {
|
|
884
|
+
const state = getState(sessionId);
|
|
885
|
+
const container = state.currentContainer;
|
|
886
|
+
if (!container) return;
|
|
887
|
+
|
|
888
|
+
// Wizard mode takes priority
|
|
889
|
+
if (state.wizardActive) {
|
|
890
|
+
renderWizard(sessionId);
|
|
891
|
+
return;
|
|
892
|
+
}
|
|
893
|
+
|
|
894
|
+
if (state.loading) {
|
|
895
|
+
container.innerHTML = `<div class="diff-viewer-loading">${S.widgets.diff_viewer.loading}</div>`;
|
|
896
|
+
return;
|
|
897
|
+
}
|
|
898
|
+
|
|
899
|
+
if (state.error) {
|
|
900
|
+
container.innerHTML = `<div class="diff-viewer-empty">${escapeHtml(state.error)}</div>`;
|
|
901
|
+
return;
|
|
902
|
+
}
|
|
903
|
+
|
|
904
|
+
if (!state.files.length) {
|
|
905
|
+
container.innerHTML = `<div class="diff-viewer-empty">${S.widgets.diff_viewer.no_files}</div>`;
|
|
906
|
+
return;
|
|
907
|
+
}
|
|
908
|
+
|
|
909
|
+
const multiFile = state.files.length > 1;
|
|
910
|
+
const file = state.files[state.currentFileIndex];
|
|
911
|
+
|
|
912
|
+
// Determine view mode: force unified if container is narrow
|
|
913
|
+
const effectiveMode = (container.offsetWidth < 600) ? 'unified' : state.viewMode;
|
|
914
|
+
|
|
915
|
+
// Build diff entries
|
|
916
|
+
const oldLines = (file.oldContent || '').split('\n');
|
|
917
|
+
const newLines = (file.newContent || '').split('\n');
|
|
918
|
+
const startLine = file.startLine || 1;
|
|
919
|
+
|
|
920
|
+
const diffEntries = generateSmartDiff(oldLines, newLines, startLine, escapeHtml, {
|
|
921
|
+
contextLines: 3,
|
|
922
|
+
collapseThreshold: 6
|
|
923
|
+
});
|
|
924
|
+
|
|
925
|
+
// Calculate stats from entries if not provided
|
|
926
|
+
if (!file.additions && !file.deletions) {
|
|
927
|
+
file.additions = diffEntries.filter(e => e.type === 'added' || e.type === 'modified').length;
|
|
928
|
+
file.deletions = diffEntries.filter(e => e.type === 'removed' || e.type === 'modified').length;
|
|
929
|
+
}
|
|
930
|
+
|
|
931
|
+
// Render diff HTML
|
|
932
|
+
const diffHtml = effectiveMode === 'split'
|
|
933
|
+
? renderSideBySideDiff(diffEntries, {
|
|
934
|
+
collapseLabel: (count) => S.widgets.diff_viewer.unchanged_lines.replace('{count}', count)
|
|
935
|
+
})
|
|
936
|
+
: `<div class="changes-diff">${renderSmartDiff(diffEntries)}</div>`;
|
|
937
|
+
|
|
938
|
+
// Build column header for split mode
|
|
939
|
+
const headerHtml = effectiveMode === 'split' ? `
|
|
940
|
+
<div class="sbs-header">
|
|
941
|
+
<div class="sbs-header-label left">${escapeHtml(file.oldLabel || S.widgets.diff_viewer.before)}</div>
|
|
942
|
+
<div class="sbs-header-gutter"></div>
|
|
943
|
+
<div class="sbs-header-label right">${escapeHtml(file.newLabel || S.widgets.diff_viewer.after)}</div>
|
|
944
|
+
</div>
|
|
945
|
+
` : '';
|
|
946
|
+
|
|
947
|
+
// Multi-file: sidebar + tab bar
|
|
948
|
+
let fileListHtml = '';
|
|
949
|
+
let fileTabsHtml = '';
|
|
950
|
+
if (multiFile) {
|
|
951
|
+
fileListHtml = '<div class="diff-viewer-file-list">';
|
|
952
|
+
fileTabsHtml = '<div class="diff-viewer-file-tabs">';
|
|
953
|
+
state.files.forEach((f, i) => {
|
|
954
|
+
const name = basename(f.path);
|
|
955
|
+
const active = i === state.currentFileIndex ? ' active' : '';
|
|
956
|
+
const add = f.additions || 0;
|
|
957
|
+
const del = f.deletions || 0;
|
|
958
|
+
const stats = `${add ? `<span class="added">+${add}</span>` : ''}${del ? `<span class="removed">-${del}</span>` : ''}`;
|
|
959
|
+
|
|
960
|
+
fileListHtml += `<div class="diff-viewer-file-item${active}" data-file-idx="${i}">
|
|
961
|
+
<span class="diff-viewer-file-name">${escapeHtml(name)}</span>
|
|
962
|
+
<span class="diff-viewer-file-stats">${stats}</span>
|
|
963
|
+
</div>`;
|
|
964
|
+
|
|
965
|
+
fileTabsHtml += `<div class="diff-viewer-file-tab${active}" data-file-idx="${i}">${escapeHtml(name)}</div>`;
|
|
966
|
+
});
|
|
967
|
+
fileListHtml += '</div>';
|
|
968
|
+
fileTabsHtml += '</div>';
|
|
969
|
+
}
|
|
970
|
+
|
|
971
|
+
const showBar = shouldShowHistoryBar(state);
|
|
972
|
+
if (showBar) {
|
|
973
|
+
const expectedKey = `${_stateCwd(state)}:${file.path}`;
|
|
974
|
+
if (state.historyKey !== expectedKey) {
|
|
975
|
+
state.historyCommits = null;
|
|
976
|
+
state.historyLoading = false;
|
|
977
|
+
state.historyIndex = -1;
|
|
978
|
+
}
|
|
979
|
+
}
|
|
980
|
+
const barHtml = showBar ? `<div class="dv-history-bar">${buildHistoryBarHtml(state)}</div>` : '';
|
|
981
|
+
|
|
982
|
+
const wrapClass = state.wrapLines ? ' wrap-lines' : '';
|
|
983
|
+
container.innerHTML = `
|
|
984
|
+
${barHtml}
|
|
985
|
+
${multiFile ? fileTabsHtml : ''}
|
|
986
|
+
<div class="diff-viewer-layout">
|
|
987
|
+
${multiFile ? fileListHtml : ''}
|
|
988
|
+
<div class="diff-viewer-content${wrapClass}">
|
|
989
|
+
${headerHtml}
|
|
990
|
+
${diffHtml}
|
|
991
|
+
</div>
|
|
992
|
+
</div>
|
|
993
|
+
`;
|
|
994
|
+
|
|
995
|
+
if (showBar) {
|
|
996
|
+
const barEl = container.querySelector('.dv-history-bar');
|
|
997
|
+
if (barEl) wireHistoryBar(state, sessionId, barEl);
|
|
998
|
+
// Trigger async load if needed; result re-renders the bar in place
|
|
999
|
+
if (state.historyCommits === null && !state.historyLoading) {
|
|
1000
|
+
loadHistoryForCurrentFile(state, sessionId);
|
|
1001
|
+
} else {
|
|
1002
|
+
// Re-align historyIndex if labels changed (e.g. after a pivot)
|
|
1003
|
+
const newIdx = findHistoryIndex(state, file);
|
|
1004
|
+
if (newIdx !== state.historyIndex) {
|
|
1005
|
+
state.historyIndex = newIdx;
|
|
1006
|
+
renderHistoryBar(state, sessionId);
|
|
1007
|
+
}
|
|
1008
|
+
}
|
|
1009
|
+
}
|
|
1010
|
+
|
|
1011
|
+
// Wire up file navigation clicks
|
|
1012
|
+
container.querySelectorAll('[data-file-idx]').forEach(el => {
|
|
1013
|
+
el.addEventListener('click', () => {
|
|
1014
|
+
state.currentFileIndex = parseInt(el.dataset.fileIdx, 10);
|
|
1015
|
+
state.currentHunkIndex = -1;
|
|
1016
|
+
renderContent(sessionId);
|
|
1017
|
+
});
|
|
1018
|
+
});
|
|
1019
|
+
|
|
1020
|
+
// Wire up collapse expand
|
|
1021
|
+
container.querySelectorAll('.sbs-collapse, .diff-collapse').forEach(el => {
|
|
1022
|
+
el.addEventListener('click', () => {
|
|
1023
|
+
// Re-render with higher context to show collapsed lines
|
|
1024
|
+
// For simplicity, remove collapse and show all context
|
|
1025
|
+
el.style.display = 'none';
|
|
1026
|
+
});
|
|
1027
|
+
});
|
|
1028
|
+
|
|
1029
|
+
// Cache hunk elements for keyboard navigation
|
|
1030
|
+
state.hunkElements = Array.from(container.querySelectorAll('[data-hunk]'));
|
|
1031
|
+
state.currentHunkIndex = -1;
|
|
1032
|
+
|
|
1033
|
+
// Update widget header summary
|
|
1034
|
+
const widget = WidgetManager.get('diff-viewer');
|
|
1035
|
+
if (widget) {
|
|
1036
|
+
const totalFiles = state.files.length;
|
|
1037
|
+
const fileLabel = totalFiles > 1 ? `${state.currentFileIndex + 1}/${totalFiles}` : basename(file.path);
|
|
1038
|
+
const modeLabel = effectiveMode === 'split' ? S.widgets.diff_viewer.split_view : S.widgets.diff_viewer.unified_view;
|
|
1039
|
+
widget.setSummary?.(`${fileLabel} · ${modeLabel}`);
|
|
1040
|
+
}
|
|
1041
|
+
|
|
1042
|
+
// Focus for keyboard nav
|
|
1043
|
+
container.tabIndex = -1;
|
|
1044
|
+
container.focus();
|
|
1045
|
+
}
|
|
1046
|
+
|
|
1047
|
+
// ═══════════════════════════════════════════════════════════════
|
|
1048
|
+
// Keyboard navigation
|
|
1049
|
+
// ═══════════════════════════════════════════════════════════════
|
|
1050
|
+
|
|
1051
|
+
function handleKeyDown(e, sessionId) {
|
|
1052
|
+
const state = getState(sessionId);
|
|
1053
|
+
|
|
1054
|
+
// Wizard keyboard nav
|
|
1055
|
+
if (state.wizardActive) {
|
|
1056
|
+
handleWizardKeyDown(e, state, sessionId);
|
|
1057
|
+
return;
|
|
1058
|
+
}
|
|
1059
|
+
|
|
1060
|
+
if (!state.files.length) return;
|
|
1061
|
+
|
|
1062
|
+
switch (e.key) {
|
|
1063
|
+
case 'j':
|
|
1064
|
+
case 'n':
|
|
1065
|
+
e.preventDefault();
|
|
1066
|
+
navigateHunk(1, state);
|
|
1067
|
+
break;
|
|
1068
|
+
case 'k':
|
|
1069
|
+
case 'p':
|
|
1070
|
+
e.preventDefault();
|
|
1071
|
+
navigateHunk(-1, state);
|
|
1072
|
+
break;
|
|
1073
|
+
case ']':
|
|
1074
|
+
e.preventDefault();
|
|
1075
|
+
navigateFile(1, state, sessionId);
|
|
1076
|
+
break;
|
|
1077
|
+
case '[':
|
|
1078
|
+
e.preventDefault();
|
|
1079
|
+
navigateFile(-1, state, sessionId);
|
|
1080
|
+
break;
|
|
1081
|
+
case 'u':
|
|
1082
|
+
e.preventDefault();
|
|
1083
|
+
toggleViewMode(state, sessionId);
|
|
1084
|
+
break;
|
|
1085
|
+
case 'Escape':
|
|
1086
|
+
e.preventDefault();
|
|
1087
|
+
WidgetManager.close('diff-viewer');
|
|
1088
|
+
break;
|
|
1089
|
+
}
|
|
1090
|
+
}
|
|
1091
|
+
|
|
1092
|
+
function handleWizardKeyDown(e, state, sessionId) {
|
|
1093
|
+
if (state.wizardStep === 'source') {
|
|
1094
|
+
// Source picker: arrow keys, Enter, number keys, Escape
|
|
1095
|
+
const totalCards = WIZARD_SOURCES.length;
|
|
1096
|
+
switch (e.key) {
|
|
1097
|
+
case 'ArrowDown':
|
|
1098
|
+
e.preventDefault();
|
|
1099
|
+
state.wizardFocusIndex = Math.min(state.wizardFocusIndex + 2, totalCards - 1);
|
|
1100
|
+
renderWizard(sessionId);
|
|
1101
|
+
break;
|
|
1102
|
+
case 'ArrowUp':
|
|
1103
|
+
e.preventDefault();
|
|
1104
|
+
state.wizardFocusIndex = Math.max(state.wizardFocusIndex - 2, 0);
|
|
1105
|
+
renderWizard(sessionId);
|
|
1106
|
+
break;
|
|
1107
|
+
case 'ArrowRight':
|
|
1108
|
+
e.preventDefault();
|
|
1109
|
+
if (state.wizardFocusIndex < totalCards - 1) state.wizardFocusIndex++;
|
|
1110
|
+
renderWizard(sessionId);
|
|
1111
|
+
break;
|
|
1112
|
+
case 'ArrowLeft':
|
|
1113
|
+
e.preventDefault();
|
|
1114
|
+
if (state.wizardFocusIndex > 0) state.wizardFocusIndex--;
|
|
1115
|
+
renderWizard(sessionId);
|
|
1116
|
+
break;
|
|
1117
|
+
case 'Enter':
|
|
1118
|
+
e.preventDefault();
|
|
1119
|
+
if (state.wizardFocusIndex >= 0 && state.wizardFocusIndex < totalCards) {
|
|
1120
|
+
wizardSelectSource(state, WIZARD_SOURCES[state.wizardFocusIndex].id, sessionId);
|
|
1121
|
+
}
|
|
1122
|
+
break;
|
|
1123
|
+
case '1': case '2': case '3': case '4': case '5': {
|
|
1124
|
+
e.preventDefault();
|
|
1125
|
+
const idx = parseInt(e.key) - 1;
|
|
1126
|
+
if (idx < totalCards) wizardSelectSource(state, WIZARD_SOURCES[idx].id, sessionId);
|
|
1127
|
+
break;
|
|
1128
|
+
}
|
|
1129
|
+
case 'Escape':
|
|
1130
|
+
e.preventDefault();
|
|
1131
|
+
WidgetManager.close('diff-viewer');
|
|
1132
|
+
break;
|
|
1133
|
+
}
|
|
1134
|
+
} else {
|
|
1135
|
+
// Picker: up/down to navigate, Enter to select, Backspace/Escape to go back
|
|
1136
|
+
// Skip keyboard list nav if file search input is focused
|
|
1137
|
+
const isSearchFocused = state.currentContainer?.querySelector('.cw-file-search') === document.activeElement;
|
|
1138
|
+
|
|
1139
|
+
switch (e.key) {
|
|
1140
|
+
case 'ArrowDown':
|
|
1141
|
+
if (isSearchFocused) return; // let input handle cursor
|
|
1142
|
+
e.preventDefault();
|
|
1143
|
+
state.wizardFocusIndex = Math.min(state.wizardFocusIndex + 1, state.wizardPickerData.length - 1);
|
|
1144
|
+
updateFocusHighlight(state);
|
|
1145
|
+
break;
|
|
1146
|
+
case 'ArrowUp':
|
|
1147
|
+
if (isSearchFocused) return;
|
|
1148
|
+
e.preventDefault();
|
|
1149
|
+
state.wizardFocusIndex = Math.max(state.wizardFocusIndex - 1, 0);
|
|
1150
|
+
updateFocusHighlight(state);
|
|
1151
|
+
break;
|
|
1152
|
+
case 'Enter':
|
|
1153
|
+
e.preventDefault();
|
|
1154
|
+
if (state.wizardStep === 'file' && state.wizardFocusIndex >= 0) {
|
|
1155
|
+
const items = state.currentContainer?.querySelectorAll('.cw-file-item');
|
|
1156
|
+
const item = items?.[state.wizardFocusIndex];
|
|
1157
|
+
if (item) wizardPickFile(state, sessionId, item.dataset.path);
|
|
1158
|
+
} else if (state.wizardFocusIndex >= 0) {
|
|
1159
|
+
const items = state.currentContainer?.querySelectorAll('.cw-item');
|
|
1160
|
+
const item = items?.[state.wizardFocusIndex];
|
|
1161
|
+
if (item) wizardPickItem(state, sessionId, item.dataset);
|
|
1162
|
+
}
|
|
1163
|
+
break;
|
|
1164
|
+
case 'Backspace':
|
|
1165
|
+
if (isSearchFocused && state.wizardFileQuery) return; // let input handle delete
|
|
1166
|
+
e.preventDefault();
|
|
1167
|
+
state.wizardStep = 'source';
|
|
1168
|
+
state.wizardFocusIndex = -1;
|
|
1169
|
+
renderWizard(sessionId);
|
|
1170
|
+
break;
|
|
1171
|
+
case 'Escape':
|
|
1172
|
+
e.preventDefault();
|
|
1173
|
+
state.wizardStep = 'source';
|
|
1174
|
+
state.wizardFocusIndex = -1;
|
|
1175
|
+
renderWizard(sessionId);
|
|
1176
|
+
break;
|
|
1177
|
+
}
|
|
1178
|
+
}
|
|
1179
|
+
}
|
|
1180
|
+
|
|
1181
|
+
function updateFocusHighlight(state) {
|
|
1182
|
+
const container = state.currentContainer;
|
|
1183
|
+
if (!container) return;
|
|
1184
|
+
container.querySelectorAll('.cw-focused').forEach(el => el.classList.remove('cw-focused'));
|
|
1185
|
+
const items = container.querySelectorAll('.cw-item, .cw-file-item');
|
|
1186
|
+
if (state.wizardFocusIndex >= 0 && state.wizardFocusIndex < items.length) {
|
|
1187
|
+
items[state.wizardFocusIndex].classList.add('cw-focused');
|
|
1188
|
+
items[state.wizardFocusIndex].scrollIntoView({ block: 'nearest' });
|
|
1189
|
+
}
|
|
1190
|
+
}
|
|
1191
|
+
|
|
1192
|
+
function navigateHunk(direction, state) {
|
|
1193
|
+
if (!state.hunkElements.length) return;
|
|
1194
|
+
|
|
1195
|
+
// Find unique hunk indices
|
|
1196
|
+
const hunkIds = [...new Set(state.hunkElements.map(el => el.dataset.hunk))];
|
|
1197
|
+
if (!hunkIds.length) return;
|
|
1198
|
+
|
|
1199
|
+
// Move to next/prev hunk
|
|
1200
|
+
state.currentHunkIndex += direction;
|
|
1201
|
+
if (state.currentHunkIndex < 0) state.currentHunkIndex = 0;
|
|
1202
|
+
if (state.currentHunkIndex >= hunkIds.length) state.currentHunkIndex = hunkIds.length - 1;
|
|
1203
|
+
|
|
1204
|
+
const targetHunkId = hunkIds[state.currentHunkIndex];
|
|
1205
|
+
const targetEl = state.hunkElements.find(el => el.dataset.hunk === targetHunkId);
|
|
1206
|
+
if (targetEl) {
|
|
1207
|
+
// Clear old highlight
|
|
1208
|
+
state.currentContainer?.querySelectorAll('.sbs-hunk-active').forEach(el => el.classList.remove('sbs-hunk-active'));
|
|
1209
|
+
|
|
1210
|
+
// Highlight all elements in this hunk
|
|
1211
|
+
state.hunkElements
|
|
1212
|
+
.filter(el => el.dataset.hunk === targetHunkId)
|
|
1213
|
+
.forEach(el => el.classList.add('sbs-hunk-active'));
|
|
1214
|
+
|
|
1215
|
+
targetEl.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
|
1216
|
+
}
|
|
1217
|
+
}
|
|
1218
|
+
|
|
1219
|
+
function navigateFile(direction, state, sessionId) {
|
|
1220
|
+
if (state.files.length <= 1) return;
|
|
1221
|
+
const newIdx = state.currentFileIndex + direction;
|
|
1222
|
+
if (newIdx < 0 || newIdx >= state.files.length) return;
|
|
1223
|
+
state.currentFileIndex = newIdx;
|
|
1224
|
+
state.currentHunkIndex = -1;
|
|
1225
|
+
renderContent(sessionId);
|
|
1226
|
+
}
|
|
1227
|
+
|
|
1228
|
+
function toggleViewMode(state, sessionId) {
|
|
1229
|
+
state.viewMode = state.viewMode === 'split' ? 'unified' : 'split';
|
|
1230
|
+
saveViewMode(state.viewMode);
|
|
1231
|
+
renderContent(sessionId);
|
|
1232
|
+
}
|
|
1233
|
+
|
|
1234
|
+
function toggleWrapLines(state, btn) {
|
|
1235
|
+
state.wrapLines = !state.wrapLines;
|
|
1236
|
+
saveWrapLines(state.wrapLines);
|
|
1237
|
+
const content = state.currentContainer?.querySelector('.diff-viewer-content');
|
|
1238
|
+
if (content) content.classList.toggle('wrap-lines', state.wrapLines);
|
|
1239
|
+
btn?.classList.toggle('is-active', state.wrapLines);
|
|
1240
|
+
}
|
|
1241
|
+
|
|
1242
|
+
// ═══════════════════════════════════════════════════════════════
|
|
1243
|
+
// Helpers
|
|
1244
|
+
// ═══════════════════════════════════════════════════════════════
|
|
1245
|
+
|
|
1246
|
+
function timeAgo(timestamp) {
|
|
1247
|
+
const now = Math.floor(Date.now() / 1000);
|
|
1248
|
+
const diff = now - timestamp;
|
|
1249
|
+
if (diff < 60) return '<1m';
|
|
1250
|
+
if (diff < 3600) return `${Math.floor(diff / 60)}m`;
|
|
1251
|
+
if (diff < 86400) return `${Math.floor(diff / 3600)}h`;
|
|
1252
|
+
if (diff < 604800) return `${Math.floor(diff / 86400)}d`;
|
|
1253
|
+
return `${Math.floor(diff / 604800)}w`;
|
|
1254
|
+
}
|
|
1255
|
+
|
|
1256
|
+
// ═══════════════════════════════════════════════════════════════
|
|
1257
|
+
// Widget registration
|
|
1258
|
+
// ═══════════════════════════════════════════════════════════════
|
|
1259
|
+
|
|
1260
|
+
export function registerDiffViewerWidget() {
|
|
1261
|
+
WidgetManager.register('diff-viewer', {
|
|
1262
|
+
type: 'floating',
|
|
1263
|
+
title: S.widgets.titles.diff_viewer,
|
|
1264
|
+
icon: 'columns',
|
|
1265
|
+
scope: 'session',
|
|
1266
|
+
hiddenInPicker: true,
|
|
1267
|
+
|
|
1268
|
+
deviceTypes: {
|
|
1269
|
+
default: 'floating',
|
|
1270
|
+
phone: 'bottom-sheet',
|
|
1271
|
+
tablet: 'floating',
|
|
1272
|
+
desktop: 'floating'
|
|
1273
|
+
},
|
|
1274
|
+
|
|
1275
|
+
allowedTypes: ['floating', 'bottom-sheet', 'tab'],
|
|
1276
|
+
|
|
1277
|
+
heights: { half: '55vh', full: '90vh' },
|
|
1278
|
+
|
|
1279
|
+
headerActions: [
|
|
1280
|
+
{
|
|
1281
|
+
icon: 'wrapText',
|
|
1282
|
+
title: S.widgets.diff_viewer.toggle_wrap,
|
|
1283
|
+
onClick: (e) => {
|
|
1284
|
+
const state = getState(WidgetManager.currentSessionId);
|
|
1285
|
+
toggleWrapLines(state, e.currentTarget);
|
|
1286
|
+
}
|
|
1287
|
+
},
|
|
1288
|
+
{
|
|
1289
|
+
icon: 'columns',
|
|
1290
|
+
title: S.widgets.diff_viewer.toggle_view,
|
|
1291
|
+
onClick: () => {
|
|
1292
|
+
const sessionId = WidgetManager.currentSessionId;
|
|
1293
|
+
const state = getState(sessionId);
|
|
1294
|
+
toggleViewMode(state, sessionId);
|
|
1295
|
+
}
|
|
1296
|
+
}
|
|
1297
|
+
],
|
|
1298
|
+
|
|
1299
|
+
render: (container, ctx) => {
|
|
1300
|
+
const sessionId = ctx.sessionId || WidgetManager.currentSessionId;
|
|
1301
|
+
const state = getState(sessionId);
|
|
1302
|
+
state.currentContainer = container;
|
|
1303
|
+
|
|
1304
|
+
// Wizard mode: open from openCompareWizard()
|
|
1305
|
+
if (ctx.wizardMode) {
|
|
1306
|
+
state.wizardActive = true;
|
|
1307
|
+
state.wizardStep = 'source';
|
|
1308
|
+
state.wizardFilePath = ctx.wizardFilePath;
|
|
1309
|
+
state.wizardFileContent = ctx.wizardFileContent;
|
|
1310
|
+
state.wizardCwd = ctx.wizardCwd;
|
|
1311
|
+
state.wizardFocusIndex = -1;
|
|
1312
|
+
state.wizardPickerData = [];
|
|
1313
|
+
state.wizardFileQuery = '';
|
|
1314
|
+
state.wizardAllFiles = null;
|
|
1315
|
+
state.files = [];
|
|
1316
|
+
state.loading = false;
|
|
1317
|
+
state.error = null;
|
|
1318
|
+
}
|
|
1319
|
+
|
|
1320
|
+
// Normal mode: files passed directly
|
|
1321
|
+
if (ctx.files) {
|
|
1322
|
+
state.wizardActive = false;
|
|
1323
|
+
state.files = ctx.files;
|
|
1324
|
+
state.source = ctx.source || 'unknown';
|
|
1325
|
+
state.currentFileIndex = ctx.fileIndex || 0;
|
|
1326
|
+
state.viewMode = loadViewMode() || ((container.offsetWidth >= 600) ? 'split' : 'unified');
|
|
1327
|
+
state.currentHunkIndex = -1;
|
|
1328
|
+
}
|
|
1329
|
+
|
|
1330
|
+
renderContent(sessionId);
|
|
1331
|
+
|
|
1332
|
+
// Mark the wrap toggle as active if wrap mode is on.
|
|
1333
|
+
const wrapBtn = container.closest('.widget')
|
|
1334
|
+
?.querySelector(`.widget-actions [data-tooltip="${S.widgets.diff_viewer.toggle_wrap}"]`);
|
|
1335
|
+
if (wrapBtn && state.wrapLines) wrapBtn.classList.add('is-active');
|
|
1336
|
+
|
|
1337
|
+
// Keyboard handler
|
|
1338
|
+
container.onkeydown = (e) => handleKeyDown(e, sessionId);
|
|
1339
|
+
},
|
|
1340
|
+
|
|
1341
|
+
onDestroy: (sessionId) => {
|
|
1342
|
+
destroyState(sessionId);
|
|
1343
|
+
}
|
|
1344
|
+
});
|
|
1345
|
+
}
|
|
1346
|
+
|
|
1347
|
+
// ═══════════════════════════════════════════════════════════════
|
|
1348
|
+
// Public API
|
|
1349
|
+
// ═══════════════════════════════════════════════════════════════
|
|
1350
|
+
|
|
1351
|
+
export const DiffViewerWidget = {
|
|
1352
|
+
/**
|
|
1353
|
+
* Open with explicit old/new content (Edit tool blocks, Changes widget)
|
|
1354
|
+
*/
|
|
1355
|
+
openWithContent(filePath, oldContent, newContent, options = {}) {
|
|
1356
|
+
const file = {
|
|
1357
|
+
path: filePath || 'untitled',
|
|
1358
|
+
oldContent: oldContent || '',
|
|
1359
|
+
newContent: newContent || '',
|
|
1360
|
+
oldLabel: options.oldLabel || S.widgets.diff_viewer.before,
|
|
1361
|
+
newLabel: options.newLabel || S.widgets.diff_viewer.after,
|
|
1362
|
+
startLine: options.startLine || 1,
|
|
1363
|
+
additions: 0,
|
|
1364
|
+
deletions: 0
|
|
1365
|
+
};
|
|
1366
|
+
WidgetManager.open('diff-viewer', {
|
|
1367
|
+
files: [file],
|
|
1368
|
+
source: options.source || 'edit-tool',
|
|
1369
|
+
fileIndex: 0
|
|
1370
|
+
});
|
|
1371
|
+
},
|
|
1372
|
+
|
|
1373
|
+
/**
|
|
1374
|
+
* Open with multiple files (multi-file git diff)
|
|
1375
|
+
*/
|
|
1376
|
+
openWithFiles(files, options = {}) {
|
|
1377
|
+
WidgetManager.open('diff-viewer', {
|
|
1378
|
+
files,
|
|
1379
|
+
source: options.source || 'diff',
|
|
1380
|
+
fileIndex: options.fileIndex || 0,
|
|
1381
|
+
});
|
|
1382
|
+
},
|
|
1383
|
+
|
|
1384
|
+
/**
|
|
1385
|
+
* Open the compare wizard for a file
|
|
1386
|
+
*/
|
|
1387
|
+
async openCompareWizard(filePath, cwd) {
|
|
1388
|
+
cwd = cwd || WidgetManager.currentCwd || '';
|
|
1389
|
+
// Load the file's current content
|
|
1390
|
+
let content = '';
|
|
1391
|
+
try {
|
|
1392
|
+
const resp = await fetch(`${CONFIG.API_BASE}/api/file?path=${encodeURIComponent(filePath)}&cwd=${encodeURIComponent(cwd)}`);
|
|
1393
|
+
if (resp.ok) {
|
|
1394
|
+
const data = await resp.json();
|
|
1395
|
+
content = data.content || '';
|
|
1396
|
+
}
|
|
1397
|
+
} catch (err) {
|
|
1398
|
+
console.error('[DiffViewer] Failed to load file for wizard:', err);
|
|
1399
|
+
}
|
|
1400
|
+
|
|
1401
|
+
WidgetManager.open('diff-viewer', {
|
|
1402
|
+
wizardMode: true,
|
|
1403
|
+
wizardFilePath: filePath,
|
|
1404
|
+
wizardFileContent: content,
|
|
1405
|
+
wizardCwd: cwd
|
|
1406
|
+
});
|
|
1407
|
+
},
|
|
1408
|
+
|
|
1409
|
+
// Edit data cache for tool-renderer onclick
|
|
1410
|
+
cacheEditData,
|
|
1411
|
+
openFromCache,
|
|
1412
|
+
|
|
1413
|
+
// Quick compare presets (bypass wizard)
|
|
1414
|
+
quickCompareGitHead,
|
|
1415
|
+
quickComparePreviousCommit,
|
|
1416
|
+
|
|
1417
|
+
// State access
|
|
1418
|
+
getState,
|
|
1419
|
+
destroyState
|
|
1420
|
+
};
|