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,869 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Inline edit for rendered markdown
|
|
3
|
+
*
|
|
4
|
+
* Click on any rendered block (paragraph, heading, list item, blockquote)
|
|
5
|
+
* to edit its raw markdown source inline. Press Enter to save, Escape to cancel.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { state, fns, activateStateInstance } from './preview-state.js';
|
|
9
|
+
import { showToast } from './preview-utils.js';
|
|
10
|
+
import { appConfirm } from '../utils.js';
|
|
11
|
+
import { CONFIG } from '../config.js';
|
|
12
|
+
import S from '../strings.js';
|
|
13
|
+
|
|
14
|
+
let currentEdit = null;
|
|
15
|
+
let discardConfirmOpen = false;
|
|
16
|
+
|
|
17
|
+
// ─── Per-instance state resolution ──────────────────────────────────────
|
|
18
|
+
// The preview has one PreviewState per widget tab / per session (see
|
|
19
|
+
// preview-state.js), but this module used to keep the edit-mode flag in a
|
|
20
|
+
// module-level boolean — so a floating preview and a tab preview shared one
|
|
21
|
+
// flag, and whichever instance toggled last won. Symptoms: pencil not
|
|
22
|
+
// highlighting, Esc reporting "edit mode off" while the visible pane stayed
|
|
23
|
+
// active, `e` acting on a hidden instance. The flag now lives on PreviewState
|
|
24
|
+
// (state.inlineEdit); each rendered instance stamps its state onto its
|
|
25
|
+
// `.file-preview-widget` host so callers holding only a DOM node (Esc handler,
|
|
26
|
+
// keyboard shortcut) can resolve the right instance.
|
|
27
|
+
|
|
28
|
+
/** Normalize any preview-related element to its `.file-preview-widget` host. */
|
|
29
|
+
function hostOf(el) {
|
|
30
|
+
if (!el) return null;
|
|
31
|
+
if (el.classList?.contains('file-preview-widget')) return el;
|
|
32
|
+
return el.querySelector?.('.file-preview-widget')
|
|
33
|
+
|| el.closest?.('.file-preview-widget')
|
|
34
|
+
|| el;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Resolve the PreviewState instance owning `el`; falls back to the active state. */
|
|
38
|
+
function stateFor(el) {
|
|
39
|
+
return hostOf(el)?._previewState || state;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const TRASH_SVG = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/><line x1="10" y1="11" x2="10" y2="17"/><line x1="14" y1="11" x2="14" y2="17"/></svg>';
|
|
43
|
+
|
|
44
|
+
const RESTORE_SVG = '<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="1 4 1 10 7 10"/><path d="M3.51 15a9 9 0 1 0 2.13-9.36L1 10"/></svg>';
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* POST new file content, throwing the server's own reason on failure.
|
|
48
|
+
* ("Path not allowed" / "Permission denied" / "HTTP 500" beats a bare
|
|
49
|
+
* "Save failed" that hides why the write was rejected.)
|
|
50
|
+
*/
|
|
51
|
+
async function writeSource(content) {
|
|
52
|
+
const resp = await fetch(`${CONFIG.API_BASE}/api/file/write`, {
|
|
53
|
+
method: 'POST',
|
|
54
|
+
headers: { 'Content-Type': 'application/json' },
|
|
55
|
+
body: JSON.stringify({ path: state.currentPath, content })
|
|
56
|
+
});
|
|
57
|
+
if (!resp.ok) {
|
|
58
|
+
const data = await resp.json().catch(() => ({}));
|
|
59
|
+
throw new Error(data.detail || `HTTP ${resp.status}`);
|
|
60
|
+
}
|
|
61
|
+
return await resp.json().catch(() => ({}));
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** "Save failed — Path not allowed" */
|
|
65
|
+
function saveFailedMsg(err, base) {
|
|
66
|
+
const label = base || S.preview?.inline_edit_save_failed || 'Save failed';
|
|
67
|
+
const reason = err?.message && err.message !== 'Save failed' ? err.message : '';
|
|
68
|
+
return reason ? `${label} — ${reason}` : label;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// Optional `el`: resolve the instance owning that element (event handlers);
|
|
72
|
+
// without it, reports the ACTIVE instance (render-time callers, where the
|
|
73
|
+
// module state pointer is guaranteed correct).
|
|
74
|
+
export function isInlineEditActive(el) { return !!stateFor(el).inlineEdit; }
|
|
75
|
+
|
|
76
|
+
// True while a block's textarea is open — used by the file-change poll to avoid
|
|
77
|
+
// a rerenderContent() that would wipe the in-progress edit (and its keystrokes).
|
|
78
|
+
export function isInlineEditInProgress() { return !!currentEdit; }
|
|
79
|
+
|
|
80
|
+
export function toggleInlineEdit(container) {
|
|
81
|
+
const host = hostOf(container);
|
|
82
|
+
const s = host?._previewState || state;
|
|
83
|
+
// The user is engaging with THIS instance — make it the active one, so the
|
|
84
|
+
// downstream machinery keyed on the module state pointer (rerenderContent,
|
|
85
|
+
// save paths) targets the pane the user is actually looking at.
|
|
86
|
+
activateStateInstance(s);
|
|
87
|
+
s.inlineEdit = !s.inlineEdit;
|
|
88
|
+
const active = s.inlineEdit;
|
|
89
|
+
const scope = host || container;
|
|
90
|
+
const rendered = scope?.querySelector('.preview-rendered');
|
|
91
|
+
if (rendered) {
|
|
92
|
+
rendered.classList.toggle('inline-edit-mode', active);
|
|
93
|
+
}
|
|
94
|
+
const btn = scope?.querySelector('.inline-edit-toggle');
|
|
95
|
+
if (btn) {
|
|
96
|
+
btn.classList.toggle('active', active);
|
|
97
|
+
btn.setAttribute('data-tooltip', active
|
|
98
|
+
? (S.preview?.inline_edit_disable || 'Disable inline editing')
|
|
99
|
+
: (S.preview?.inline_edit_enable || 'Click to edit'));
|
|
100
|
+
}
|
|
101
|
+
// Cancel any in-progress edit when toggling THIS instance off
|
|
102
|
+
if (!active && currentEdit && (!scope || scope.contains(currentEdit.element))) {
|
|
103
|
+
cancelEdit();
|
|
104
|
+
}
|
|
105
|
+
return active;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export function setInlineEdit(val) {
|
|
109
|
+
state.inlineEdit = val;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Setup inline edit click handlers on rendered markdown container
|
|
114
|
+
*/
|
|
115
|
+
export function setupInlineEdit(container) {
|
|
116
|
+
// Setup runs during render, when the module state pointer IS this
|
|
117
|
+
// instance's state (render() calls activateState first). Capture it and
|
|
118
|
+
// stamp it on the host so later interactions resolve the right instance.
|
|
119
|
+
const s = state;
|
|
120
|
+
const host = hostOf(container);
|
|
121
|
+
if (host) host._previewState = s;
|
|
122
|
+
|
|
123
|
+
const rendered = container.querySelector('.preview-rendered');
|
|
124
|
+
if (!rendered) return;
|
|
125
|
+
|
|
126
|
+
// Restore mode class if re-rendered while this instance is active
|
|
127
|
+
if (s.inlineEdit) {
|
|
128
|
+
rendered.classList.add('inline-edit-mode');
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// Attach trash button to each selectable (hidden via CSS; shown on hover in edit mode)
|
|
132
|
+
addTrashButtons(rendered);
|
|
133
|
+
|
|
134
|
+
rendered.addEventListener('click', (e) => {
|
|
135
|
+
if (!s.inlineEdit) return;
|
|
136
|
+
// Clicked into this instance — the pointer-keyed machinery follows.
|
|
137
|
+
activateStateInstance(s);
|
|
138
|
+
|
|
139
|
+
// Trash click → delete block directly (no edit step)
|
|
140
|
+
const trashBtn = e.target.closest('.inline-edit-trash-btn');
|
|
141
|
+
if (trashBtn) {
|
|
142
|
+
e.preventDefault();
|
|
143
|
+
e.stopPropagation();
|
|
144
|
+
const block = trashBtn.closest('[data-selectable]');
|
|
145
|
+
if (!block) return;
|
|
146
|
+
// If editing another block, ignore. If editing this block, cancel first.
|
|
147
|
+
if (currentEdit && currentEdit.element !== block) return;
|
|
148
|
+
if (currentEdit && currentEdit.element === block) cancelEdit();
|
|
149
|
+
deleteBlockByElement(block);
|
|
150
|
+
return;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
if (currentEdit) return;
|
|
154
|
+
|
|
155
|
+
// Find the closest selectable element. Skip nested ones — outer wins,
|
|
156
|
+
// so there are no edits-inside-edits.
|
|
157
|
+
const target = e.target.closest('[data-selectable]:not(.inline-edit-nested)');
|
|
158
|
+
if (!target) return;
|
|
159
|
+
if (target.classList.contains('inline-edit-placeholder')) return;
|
|
160
|
+
if (target.querySelector('.inline-edit-textarea')) return;
|
|
161
|
+
|
|
162
|
+
e.preventDefault();
|
|
163
|
+
e.stopPropagation();
|
|
164
|
+
|
|
165
|
+
const visualOffset = getClickTextOffset(target, e.clientX, e.clientY);
|
|
166
|
+
startEdit(target, rendered, visualOffset);
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
// Toggle button
|
|
170
|
+
const toggleBtn = container.querySelector('.inline-edit-toggle');
|
|
171
|
+
if (toggleBtn) {
|
|
172
|
+
toggleBtn.addEventListener('click', (e) => {
|
|
173
|
+
e.preventDefault();
|
|
174
|
+
toggleInlineEdit(container);
|
|
175
|
+
});
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function addTrashButtons(rendered) {
|
|
180
|
+
const selectables = rendered.querySelectorAll('[data-selectable]');
|
|
181
|
+
selectables.forEach(el => {
|
|
182
|
+
if (el.classList.contains('inline-edit-placeholder')) return;
|
|
183
|
+
// Suppress nested selectables: if any ancestor inside `rendered` also has
|
|
184
|
+
// [data-selectable], this one is inert (no trash, no hover outline). The
|
|
185
|
+
// outer block is the edit target.
|
|
186
|
+
if (hasSelectableAncestor(el, rendered)) {
|
|
187
|
+
el.classList.add('inline-edit-nested');
|
|
188
|
+
return;
|
|
189
|
+
}
|
|
190
|
+
el.classList.remove('inline-edit-nested');
|
|
191
|
+
if (el.querySelector(':scope > .inline-edit-trash-btn')) return;
|
|
192
|
+
const btn = document.createElement('button');
|
|
193
|
+
btn.type = 'button';
|
|
194
|
+
btn.className = 'inline-edit-trash-btn';
|
|
195
|
+
btn.setAttribute('data-tooltip', S.preview?.inline_edit_delete_tooltip || 'Delete this block');
|
|
196
|
+
btn.setAttribute('contenteditable', 'false');
|
|
197
|
+
btn.innerHTML = TRASH_SVG;
|
|
198
|
+
// mousedown preventDefault keeps textarea focus intact while editing
|
|
199
|
+
btn.addEventListener('mousedown', (e) => e.preventDefault());
|
|
200
|
+
el.appendChild(btn);
|
|
201
|
+
});
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function hasSelectableAncestor(el, root) {
|
|
205
|
+
let cur = el.parentElement;
|
|
206
|
+
while (cur && cur !== root) {
|
|
207
|
+
if (cur.hasAttribute('data-selectable')) return true;
|
|
208
|
+
cur = cur.parentElement;
|
|
209
|
+
}
|
|
210
|
+
return false;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// ─── Edit lifecycle ────────────────────────────────────────────────────
|
|
214
|
+
|
|
215
|
+
function startEdit(element, _rendered, visualOffset = null) {
|
|
216
|
+
const renderedText = element.textContent.trim();
|
|
217
|
+
if (!renderedText) return;
|
|
218
|
+
|
|
219
|
+
// DOM source indexes refer to the content at render time. If deletes are
|
|
220
|
+
// pending (no re-render since), use the saved base. Otherwise state.content.
|
|
221
|
+
const base = state._deleteBase || state.content;
|
|
222
|
+
|
|
223
|
+
// Prefer source map attributes injected during rendering
|
|
224
|
+
let sourceInfo = null;
|
|
225
|
+
const srcStart = element.getAttribute('data-source-start');
|
|
226
|
+
const srcEnd = element.getAttribute('data-source-end');
|
|
227
|
+
if (srcStart != null && srcEnd != null && base) {
|
|
228
|
+
const startIdx = parseInt(srcStart, 10);
|
|
229
|
+
const endIdx = parseInt(srcEnd, 10);
|
|
230
|
+
const raw = base.substring(startIdx, endIdx);
|
|
231
|
+
// Trim trailing newline from raw token (marked includes it) for cleaner editing
|
|
232
|
+
const trimmed = raw.replace(/\n+$/, '');
|
|
233
|
+
sourceInfo = { raw: trimmed, startIdx, endIdx: startIdx + trimmed.length };
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
// Fallback to fuzzy text matching when source map is unavailable
|
|
237
|
+
if (!sourceInfo) {
|
|
238
|
+
sourceInfo = findSourceMarkdown(base, renderedText, element);
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
if (!sourceInfo) {
|
|
242
|
+
showToast(S.preview?.inline_edit_not_found || 'Could not locate in source');
|
|
243
|
+
return;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
const { raw, startIdx, endIdx } = sourceInfo;
|
|
247
|
+
|
|
248
|
+
// Store original state
|
|
249
|
+
const originalHtml = element.innerHTML;
|
|
250
|
+
element.classList.add('inline-editing');
|
|
251
|
+
|
|
252
|
+
// Create textarea with raw markdown
|
|
253
|
+
const textarea = document.createElement('textarea');
|
|
254
|
+
textarea.className = 'inline-edit-textarea';
|
|
255
|
+
// rows defaults to 2, and autoResize measures scrollHeight after setting
|
|
256
|
+
// height:auto — i.e. against the intrinsic 2-row box — so without this every
|
|
257
|
+
// single-line block grew by a full line on entering edit (a heading by its own
|
|
258
|
+
// larger line-height). rows=1 makes the floor one line; scrollHeight still
|
|
259
|
+
// reports the true height for anything taller.
|
|
260
|
+
textarea.rows = 1;
|
|
261
|
+
// Block only the keys the textarea handles itself (Enter/Escape/Tab) —
|
|
262
|
+
// global shortcuts like Ctrl+/ (focus chat input) must still fire.
|
|
263
|
+
textarea.dataset.shortcutsDisabled = 'enter,escape,tab';
|
|
264
|
+
textarea.value = raw;
|
|
265
|
+
textarea.spellcheck = true;
|
|
266
|
+
|
|
267
|
+
// Preserve the hover trash button (added by addTrashButtons) across content swap
|
|
268
|
+
const savedTrash = element.querySelector(':scope > .inline-edit-trash-btn');
|
|
269
|
+
|
|
270
|
+
// Replace content
|
|
271
|
+
element.textContent = '';
|
|
272
|
+
if (savedTrash) element.appendChild(savedTrash);
|
|
273
|
+
element.appendChild(textarea);
|
|
274
|
+
|
|
275
|
+
// Auto-size
|
|
276
|
+
autoResize(textarea);
|
|
277
|
+
textarea.focus();
|
|
278
|
+
|
|
279
|
+
// Place cursor where the user clicked (approximate — skips markdown syntax
|
|
280
|
+
// like `**` and leading `# `/`- `). Falls back to end of text.
|
|
281
|
+
const cursorPos = visualOffset != null
|
|
282
|
+
? mapVisualToSource(textarea.value, visualOffset)
|
|
283
|
+
: textarea.value.length;
|
|
284
|
+
textarea.selectionStart = textarea.selectionEnd = cursorPos;
|
|
285
|
+
|
|
286
|
+
currentEdit = { element, textarea, originalHtml, raw, startIdx, endIdx };
|
|
287
|
+
|
|
288
|
+
textarea.addEventListener('input', () => autoResize(textarea));
|
|
289
|
+
|
|
290
|
+
// Use capture phase to intercept before the global shortcut system
|
|
291
|
+
// (shortcuts.js registers on document with capture: true)
|
|
292
|
+
textarea.addEventListener('keydown', (e) => {
|
|
293
|
+
if (e.key === 'Enter' && !e.shiftKey) {
|
|
294
|
+
e.preventDefault();
|
|
295
|
+
e.stopPropagation();
|
|
296
|
+
commitEdit();
|
|
297
|
+
} else if (e.key === 'Escape') {
|
|
298
|
+
e.preventDefault();
|
|
299
|
+
e.stopPropagation();
|
|
300
|
+
// Don't silently drop the user's work — only auto-close when clean.
|
|
301
|
+
if (textarea.value !== currentEdit.raw) {
|
|
302
|
+
const msg = S.preview?.inline_edit_discard_confirm || 'Discard unsaved changes?';
|
|
303
|
+
discardConfirmOpen = true;
|
|
304
|
+
appConfirm(msg, { confirmLabel: 'Discard', danger: true }).then(ok => {
|
|
305
|
+
discardConfirmOpen = false;
|
|
306
|
+
if (ok) cancelEdit();
|
|
307
|
+
else textarea.focus();
|
|
308
|
+
});
|
|
309
|
+
return;
|
|
310
|
+
}
|
|
311
|
+
cancelEdit();
|
|
312
|
+
} else if (e.key === 'Tab' && !e.shiftKey) {
|
|
313
|
+
e.preventDefault();
|
|
314
|
+
e.stopPropagation();
|
|
315
|
+
const { selectionStart: start, selectionEnd: end, value } = textarea;
|
|
316
|
+
textarea.value = value.slice(0, start) + '\t' + value.slice(end);
|
|
317
|
+
textarea.selectionStart = textarea.selectionEnd = start + 1;
|
|
318
|
+
autoResize(textarea);
|
|
319
|
+
}
|
|
320
|
+
}, true);
|
|
321
|
+
|
|
322
|
+
// iOS/WebKit can fire a spurious blur immediately after a programmatic
|
|
323
|
+
// focus() made inside a click handler. Left unguarded, that blur runs the
|
|
324
|
+
// commit/cancel path below and — since nothing's been typed yet — instantly
|
|
325
|
+
// cancelEdit()s, so the block flashes into a textarea and snaps straight
|
|
326
|
+
// back to rendered ("jumps for a second and back to normal"). Ignore blurs
|
|
327
|
+
// until the focus has settled; if one slips through early, reclaim focus
|
|
328
|
+
// instead of tearing the edit down.
|
|
329
|
+
let blurArmed = false;
|
|
330
|
+
setTimeout(() => { blurArmed = true; }, 250);
|
|
331
|
+
|
|
332
|
+
// Clicking outside: commit if user made changes, cancel if untouched.
|
|
333
|
+
// Delay lets Enter/click-handlers run first; commit-on-blur avoids losing work.
|
|
334
|
+
textarea.addEventListener('blur', () => {
|
|
335
|
+
if (!blurArmed) {
|
|
336
|
+
// Focus bounce right after open — keep the just-opened edit alive.
|
|
337
|
+
if (currentEdit?.textarea === textarea && !discardConfirmOpen) {
|
|
338
|
+
textarea.focus();
|
|
339
|
+
}
|
|
340
|
+
return;
|
|
341
|
+
}
|
|
342
|
+
setTimeout(() => {
|
|
343
|
+
// Focus moved to the discard-confirm dialog, not away from the edit
|
|
344
|
+
if (discardConfirmOpen) return;
|
|
345
|
+
if (currentEdit?.textarea !== textarea) return;
|
|
346
|
+
if (textarea.value !== currentEdit.raw) {
|
|
347
|
+
commitEdit();
|
|
348
|
+
} else {
|
|
349
|
+
cancelEdit();
|
|
350
|
+
}
|
|
351
|
+
}, 200);
|
|
352
|
+
});
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
function autoResize(textarea) {
|
|
356
|
+
textarea.style.height = 'auto';
|
|
357
|
+
textarea.style.height = textarea.scrollHeight + 'px';
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
function cancelEdit() {
|
|
361
|
+
if (!currentEdit) return;
|
|
362
|
+
const { element, originalHtml } = currentEdit;
|
|
363
|
+
element.innerHTML = originalHtml;
|
|
364
|
+
element.classList.remove('inline-editing');
|
|
365
|
+
currentEdit = null;
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
async function commitEdit() {
|
|
369
|
+
if (!currentEdit) return;
|
|
370
|
+
const { textarea, raw, startIdx, endIdx } = currentEdit;
|
|
371
|
+
const newRaw = textarea.value;
|
|
372
|
+
|
|
373
|
+
if (newRaw === raw) {
|
|
374
|
+
cancelEdit();
|
|
375
|
+
return;
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
// startIdx/endIdx are into the base (pre-delete content, same indexes as DOM attrs).
|
|
379
|
+
// When deletes are pending, we splice into the base then reapply pending deletes to get
|
|
380
|
+
// the save content.
|
|
381
|
+
const base = state._deleteBase || state.content;
|
|
382
|
+
if (!base) {
|
|
383
|
+
cancelEdit();
|
|
384
|
+
return;
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
const newBase = base.substring(0, startIdx) + newRaw + base.substring(endIdx);
|
|
388
|
+
const deletes = state._deletes || [];
|
|
389
|
+
const newSaveContent = deletes.length ? buildContentWithDeletes(newBase, deletes) : newBase;
|
|
390
|
+
|
|
391
|
+
try {
|
|
392
|
+
await writeSource(newSaveContent);
|
|
393
|
+
|
|
394
|
+
state.content = newSaveContent;
|
|
395
|
+
if (state._deleteBase) state._deleteBase = newBase;
|
|
396
|
+
currentEdit = null;
|
|
397
|
+
|
|
398
|
+
// Re-render wipes pending-delete placeholders (by design: a save action finalizes them)
|
|
399
|
+
fns.rerenderContent();
|
|
400
|
+
showToast(S.toast?.file_saved || 'Saved');
|
|
401
|
+
} catch (err) {
|
|
402
|
+
console.error('[InlineEdit] Save failed:', err);
|
|
403
|
+
showToast(saveFailedMsg(err));
|
|
404
|
+
cancelEdit();
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
// ─── Delete + restore (in-place placeholder) ────────────────────────────
|
|
409
|
+
|
|
410
|
+
async function deleteBlockByElement(element) {
|
|
411
|
+
const srcStart = element.getAttribute('data-source-start');
|
|
412
|
+
const srcEnd = element.getAttribute('data-source-end');
|
|
413
|
+
if (srcStart == null || srcEnd == null) {
|
|
414
|
+
showToast(S.preview?.inline_edit_not_found || 'Could not locate in source');
|
|
415
|
+
return;
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
// On first delete of this session, snapshot state.content as the base.
|
|
419
|
+
// All data-source-start/end refer to this base.
|
|
420
|
+
if (!state._deleteBase) {
|
|
421
|
+
state._deleteBase = state.content;
|
|
422
|
+
state._deletes = [];
|
|
423
|
+
}
|
|
424
|
+
const base = state._deleteBase;
|
|
425
|
+
|
|
426
|
+
const origStart = parseInt(srcStart, 10);
|
|
427
|
+
let origEnd = parseInt(srcEnd, 10);
|
|
428
|
+
if (base[origEnd] === '\n') origEnd++;
|
|
429
|
+
|
|
430
|
+
// Build placeholder matching the original tag so we stay valid in its container
|
|
431
|
+
const placeholder = document.createElement(element.tagName);
|
|
432
|
+
placeholder.className = 'inline-edit-placeholder';
|
|
433
|
+
const labelText = S.preview?.inline_edit_deleted || 'Block deleted';
|
|
434
|
+
const restoreLabel = S.preview?.inline_edit_restore || 'Restore';
|
|
435
|
+
placeholder.innerHTML =
|
|
436
|
+
'<span class="inline-edit-placeholder-label">' + labelText + '</span>' +
|
|
437
|
+
'<button type="button" class="inline-edit-restore-btn">' +
|
|
438
|
+
RESTORE_SVG +
|
|
439
|
+
'<span>' + restoreLabel + '</span>' +
|
|
440
|
+
'</button>';
|
|
441
|
+
|
|
442
|
+
const record = { origStart, origEnd, placeholder, originalEl: element };
|
|
443
|
+
|
|
444
|
+
placeholder.querySelector('.inline-edit-restore-btn').addEventListener('click', (e) => {
|
|
445
|
+
e.preventDefault();
|
|
446
|
+
e.stopPropagation();
|
|
447
|
+
restoreDeletedBlock(record);
|
|
448
|
+
});
|
|
449
|
+
|
|
450
|
+
state._deletes.push(record);
|
|
451
|
+
element.parentNode.replaceChild(placeholder, element);
|
|
452
|
+
|
|
453
|
+
const newContent = buildContentWithDeletes(base, state._deletes);
|
|
454
|
+
|
|
455
|
+
try {
|
|
456
|
+
const result = await writeSource(newContent);
|
|
457
|
+
state.content = newContent;
|
|
458
|
+
// Update mtime so preview-poll's silentReload doesn't trigger and wipe placeholders
|
|
459
|
+
if (result.mtime) state.mtime = result.mtime;
|
|
460
|
+
} catch (err) {
|
|
461
|
+
// Revert: swap original element back, drop the record
|
|
462
|
+
if (placeholder.parentNode) {
|
|
463
|
+
placeholder.parentNode.replaceChild(element, placeholder);
|
|
464
|
+
}
|
|
465
|
+
state._deletes = state._deletes.filter(d => d !== record);
|
|
466
|
+
if (state._deletes.length === 0) {
|
|
467
|
+
delete state._deleteBase;
|
|
468
|
+
state._deletes = null;
|
|
469
|
+
}
|
|
470
|
+
console.error('[InlineEdit] Delete failed:', err);
|
|
471
|
+
showToast(saveFailedMsg(err, S.preview?.inline_edit_delete_failed || 'Delete failed'));
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
async function restoreDeletedBlock(record) {
|
|
476
|
+
const base = state._deleteBase;
|
|
477
|
+
if (!base) return;
|
|
478
|
+
|
|
479
|
+
const remaining = (state._deletes || []).filter(d => d !== record);
|
|
480
|
+
const newContent = buildContentWithDeletes(base, remaining);
|
|
481
|
+
|
|
482
|
+
try {
|
|
483
|
+
const result = await writeSource(newContent);
|
|
484
|
+
|
|
485
|
+
state.content = newContent;
|
|
486
|
+
state._deletes = remaining;
|
|
487
|
+
if (result.mtime) state.mtime = result.mtime;
|
|
488
|
+
|
|
489
|
+
// Swap placeholder back with the original element
|
|
490
|
+
if (record.placeholder.parentNode) {
|
|
491
|
+
record.placeholder.parentNode.replaceChild(record.originalEl, record.placeholder);
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
if (remaining.length === 0) {
|
|
495
|
+
delete state._deleteBase;
|
|
496
|
+
state._deletes = null;
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
showToast(S.preview?.inline_edit_restored || 'Block restored');
|
|
500
|
+
} catch (err) {
|
|
501
|
+
console.error('[InlineEdit] Restore failed:', err);
|
|
502
|
+
showToast(saveFailedMsg(err, S.preview?.inline_edit_restore_failed || 'Restore failed'));
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
/**
|
|
507
|
+
* Rebuild content by removing all pending-delete ranges from the base.
|
|
508
|
+
* Splices from highest origStart to lowest so earlier indexes stay valid.
|
|
509
|
+
*/
|
|
510
|
+
function buildContentWithDeletes(base, deletes) {
|
|
511
|
+
const sorted = [...deletes].sort((a, b) => b.origStart - a.origStart);
|
|
512
|
+
let content = base;
|
|
513
|
+
for (const d of sorted) {
|
|
514
|
+
content = content.substring(0, d.origStart) + content.substring(d.origEnd);
|
|
515
|
+
}
|
|
516
|
+
return content;
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
/**
|
|
520
|
+
* Called from rerenderContent to discard pending-delete state — placeholders
|
|
521
|
+
* are gone from the DOM after a re-render, so the tracker is no longer useful.
|
|
522
|
+
*/
|
|
523
|
+
export function resetDeleteSession() {
|
|
524
|
+
if (state._deleteBase) delete state._deleteBase;
|
|
525
|
+
if (state._deletes) state._deletes = null;
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
// ─── Source mapping ────────────────────────────────────────────────────
|
|
529
|
+
|
|
530
|
+
/**
|
|
531
|
+
* Find the raw markdown source that corresponds to the rendered text.
|
|
532
|
+
* Returns { raw, startIdx, endIdx } where raw is the source markdown
|
|
533
|
+
* and startIdx/endIdx are byte offsets into the full source string.
|
|
534
|
+
*/
|
|
535
|
+
function findSourceMarkdown(source, renderedText, element) {
|
|
536
|
+
if (!source || !renderedText) return null;
|
|
537
|
+
|
|
538
|
+
const tag = element.tagName?.toLowerCase();
|
|
539
|
+
const selType = element.getAttribute('data-selectable');
|
|
540
|
+
|
|
541
|
+
// Strategy 1: Heading — find `#{1,6} text` line
|
|
542
|
+
if (selType === 'heading' || /^h[1-6]$/.test(tag)) {
|
|
543
|
+
const headingMatch = findHeadingSource(source, renderedText);
|
|
544
|
+
if (headingMatch) return headingMatch;
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
// Strategy 2: List item — find `- text` or `* text` or `1. text` line
|
|
548
|
+
if (selType === 'bullet' || tag === 'li') {
|
|
549
|
+
const listMatch = findListItemSource(source, renderedText);
|
|
550
|
+
if (listMatch) return listMatch;
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
// Strategy 3: Blockquote — find `> text` line
|
|
554
|
+
if (selType === 'quote' || tag === 'blockquote') {
|
|
555
|
+
const quoteMatch = findBlockquoteSource(source, renderedText);
|
|
556
|
+
if (quoteMatch) return quoteMatch;
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
// Strategy 4: Direct text match (paragraphs, sentences)
|
|
560
|
+
const directMatch = findDirectTextMatch(source, renderedText);
|
|
561
|
+
if (directMatch) return directMatch;
|
|
562
|
+
|
|
563
|
+
// Strategy 5: Fuzzy line match — strip markdown and compare
|
|
564
|
+
const fuzzyMatch = findFuzzyLineMatch(source, renderedText);
|
|
565
|
+
if (fuzzyMatch) return fuzzyMatch;
|
|
566
|
+
|
|
567
|
+
return null;
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
function findHeadingSource(source, text) {
|
|
571
|
+
// Match heading lines: # Title, ## Title, etc.
|
|
572
|
+
const regex = new RegExp(`^(#{1,6}\\s+)${escapeRegex(text)}\\s*$`, 'm');
|
|
573
|
+
const match = source.match(regex);
|
|
574
|
+
if (match) {
|
|
575
|
+
const startIdx = match.index;
|
|
576
|
+
const endIdx = startIdx + match[0].length;
|
|
577
|
+
return { raw: match[0], startIdx, endIdx };
|
|
578
|
+
}
|
|
579
|
+
return null;
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
function findListItemSource(source, text) {
|
|
583
|
+
// Match list items: - text, * text, + text, 1. text
|
|
584
|
+
const regex = new RegExp(`^(\\s*(?:[-*+]|\\d+\\.)\\s+)${escapeRegex(text)}\\s*$`, 'm');
|
|
585
|
+
const match = source.match(regex);
|
|
586
|
+
if (match) {
|
|
587
|
+
const startIdx = match.index;
|
|
588
|
+
const endIdx = startIdx + match[0].length;
|
|
589
|
+
return { raw: match[0], startIdx, endIdx };
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
// Fallback: try matching just the text content within a list item line
|
|
593
|
+
const lines = source.split('\n');
|
|
594
|
+
for (let i = 0; i < lines.length; i++) {
|
|
595
|
+
const line = lines[i];
|
|
596
|
+
const listMatch = line.match(/^(\s*(?:[-*+]|\d+\.)\s+)/);
|
|
597
|
+
if (listMatch && stripInlineMarkdown(line.slice(listMatch[1].length)).trim() === text) {
|
|
598
|
+
const startIdx = getLineOffset(source, i);
|
|
599
|
+
const endIdx = startIdx + line.length;
|
|
600
|
+
return { raw: line, startIdx, endIdx };
|
|
601
|
+
}
|
|
602
|
+
}
|
|
603
|
+
return null;
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
function findBlockquoteSource(source, text) {
|
|
607
|
+
// Blockquotes can span multiple lines, each starting with >
|
|
608
|
+
const lines = source.split('\n');
|
|
609
|
+
for (let i = 0; i < lines.length; i++) {
|
|
610
|
+
if (!lines[i].match(/^>\s*/)) continue;
|
|
611
|
+
|
|
612
|
+
// Collect contiguous blockquote lines
|
|
613
|
+
let end = i;
|
|
614
|
+
while (end < lines.length && lines[end].match(/^>\s*/)) end++;
|
|
615
|
+
|
|
616
|
+
const quoteLines = lines.slice(i, end);
|
|
617
|
+
const quoteText = quoteLines.map(l => l.replace(/^>\s*/, '')).join(' ').trim();
|
|
618
|
+
|
|
619
|
+
if (quoteText === text || stripInlineMarkdown(quoteText) === text) {
|
|
620
|
+
const block = quoteLines.join('\n');
|
|
621
|
+
const startIdx = getLineOffset(source, i);
|
|
622
|
+
const endIdx = startIdx + block.length;
|
|
623
|
+
return { raw: block, startIdx, endIdx };
|
|
624
|
+
}
|
|
625
|
+
}
|
|
626
|
+
return null;
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
function findDirectTextMatch(source, text) {
|
|
630
|
+
// Try to find the text directly in the source
|
|
631
|
+
const idx = source.indexOf(text);
|
|
632
|
+
if (idx !== -1) {
|
|
633
|
+
// Expand to full line(s) — don't cut mid-line
|
|
634
|
+
const before = source.lastIndexOf('\n', idx - 1);
|
|
635
|
+
const after = source.indexOf('\n', idx + text.length);
|
|
636
|
+
const startIdx = before === -1 ? 0 : before + 1;
|
|
637
|
+
const endIdx = after === -1 ? source.length : after;
|
|
638
|
+
const raw = source.substring(startIdx, endIdx);
|
|
639
|
+
return { raw, startIdx, endIdx };
|
|
640
|
+
}
|
|
641
|
+
return null;
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
function findFuzzyLineMatch(source, text) {
|
|
645
|
+
const lines = source.split('\n');
|
|
646
|
+
|
|
647
|
+
// Single line match: strip markdown from line and compare
|
|
648
|
+
for (let i = 0; i < lines.length; i++) {
|
|
649
|
+
const stripped = stripInlineMarkdown(
|
|
650
|
+
lines[i].replace(/^#{1,6}\s+/, '')
|
|
651
|
+
.replace(/^\s*(?:[-*+]|\d+\.)\s+/, '')
|
|
652
|
+
.replace(/^>\s*/, '')
|
|
653
|
+
).trim();
|
|
654
|
+
|
|
655
|
+
if (stripped === text) {
|
|
656
|
+
const startIdx = getLineOffset(source, i);
|
|
657
|
+
const endIdx = startIdx + lines[i].length;
|
|
658
|
+
return { raw: lines[i], startIdx, endIdx };
|
|
659
|
+
}
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
// Multi-line paragraph: consecutive non-empty, non-special lines
|
|
663
|
+
for (let i = 0; i < lines.length; i++) {
|
|
664
|
+
if (isSpecialLine(lines[i]) || !lines[i].trim()) continue;
|
|
665
|
+
|
|
666
|
+
let end = i;
|
|
667
|
+
while (end < lines.length && lines[end].trim() && !isSpecialLine(lines[end])) end++;
|
|
668
|
+
|
|
669
|
+
const paraLines = lines.slice(i, end);
|
|
670
|
+
const paraText = paraLines.map(l => stripInlineMarkdown(l).trim()).join(' ');
|
|
671
|
+
|
|
672
|
+
if (paraText === text) {
|
|
673
|
+
const block = paraLines.join('\n');
|
|
674
|
+
const startIdx = getLineOffset(source, i);
|
|
675
|
+
const endIdx = startIdx + block.length;
|
|
676
|
+
return { raw: block, startIdx, endIdx };
|
|
677
|
+
}
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
return null;
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
// ─── Helpers ───────────────────────────────────────────────────────────
|
|
684
|
+
|
|
685
|
+
/**
|
|
686
|
+
* Get the text offset within `element` at the given viewport coordinates.
|
|
687
|
+
* Returns the number of characters from the start of the element's text to
|
|
688
|
+
* the click point, or null if it can't be determined.
|
|
689
|
+
*/
|
|
690
|
+
function getClickTextOffset(element, x, y) {
|
|
691
|
+
let range = null;
|
|
692
|
+
try {
|
|
693
|
+
if (document.caretRangeFromPoint) {
|
|
694
|
+
range = document.caretRangeFromPoint(x, y); // Chrome/Safari/iOS
|
|
695
|
+
} else if (document.caretPositionFromPoint) {
|
|
696
|
+
const pos = document.caretPositionFromPoint(x, y); // Firefox
|
|
697
|
+
if (pos) {
|
|
698
|
+
range = document.createRange();
|
|
699
|
+
range.setStart(pos.offsetNode, pos.offset);
|
|
700
|
+
range.collapse(true);
|
|
701
|
+
}
|
|
702
|
+
}
|
|
703
|
+
} catch (_) {
|
|
704
|
+
return null;
|
|
705
|
+
}
|
|
706
|
+
if (!range || !element.contains(range.startContainer)) return null;
|
|
707
|
+
const preRange = document.createRange();
|
|
708
|
+
preRange.selectNodeContents(element);
|
|
709
|
+
preRange.setEnd(range.startContainer, range.startOffset);
|
|
710
|
+
return preRange.toString().length;
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
/**
|
|
714
|
+
* Map a visual text offset (in the rendered DOM) to an offset in the raw
|
|
715
|
+
* markdown source. Heuristic — skips inline emphasis markers and the leading
|
|
716
|
+
* line marker on the first line. Good enough for paragraphs, headings, and
|
|
717
|
+
* simple list items; degrades gracefully for complex inline markdown.
|
|
718
|
+
*/
|
|
719
|
+
function mapVisualToSource(source, visualOffset) {
|
|
720
|
+
let srcIdx = 0;
|
|
721
|
+
const leadMatch = source.match(/^(\s*(?:#{1,6}\s+|[-*+]\s+|\d+\.\s+|>\s+))/);
|
|
722
|
+
if (leadMatch) srcIdx = leadMatch[0].length;
|
|
723
|
+
if (visualOffset <= 0) return srcIdx;
|
|
724
|
+
|
|
725
|
+
let visIdx = 0;
|
|
726
|
+
while (srcIdx < source.length && visIdx < visualOffset) {
|
|
727
|
+
const c = source[srcIdx];
|
|
728
|
+
if (c === '*' || c === '_' || c === '`' || c === '~') {
|
|
729
|
+
srcIdx++;
|
|
730
|
+
continue;
|
|
731
|
+
}
|
|
732
|
+
if (c === '[') {
|
|
733
|
+
srcIdx++;
|
|
734
|
+
continue;
|
|
735
|
+
}
|
|
736
|
+
if (c === ']' && source[srcIdx + 1] === '(') {
|
|
737
|
+
const close = source.indexOf(')', srcIdx + 2);
|
|
738
|
+
if (close !== -1) {
|
|
739
|
+
srcIdx = close + 1;
|
|
740
|
+
continue;
|
|
741
|
+
}
|
|
742
|
+
}
|
|
743
|
+
srcIdx++;
|
|
744
|
+
visIdx++;
|
|
745
|
+
}
|
|
746
|
+
return srcIdx;
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
function getLineOffset(source, lineIndex) {
|
|
750
|
+
let offset = 0;
|
|
751
|
+
const lines = source.split('\n');
|
|
752
|
+
for (let i = 0; i < lineIndex && i < lines.length; i++) {
|
|
753
|
+
offset += lines[i].length + 1; // +1 for \n
|
|
754
|
+
}
|
|
755
|
+
return offset;
|
|
756
|
+
}
|
|
757
|
+
|
|
758
|
+
function isSpecialLine(line) {
|
|
759
|
+
return /^(#{1,6}\s|[-*+]\s|\d+\.\s|>\s|```|---|\*\*\*|___|\|)/.test(line.trim());
|
|
760
|
+
}
|
|
761
|
+
|
|
762
|
+
function stripInlineMarkdown(text) {
|
|
763
|
+
return text
|
|
764
|
+
.replace(/\*\*\*(.*?)\*\*\*/g, '$1') // bold+italic
|
|
765
|
+
.replace(/\*\*(.*?)\*\*/g, '$1') // bold
|
|
766
|
+
.replace(/\*(.*?)\*/g, '$1') // italic
|
|
767
|
+
.replace(/~~(.*?)~~/g, '$1') // strikethrough
|
|
768
|
+
.replace(/`(.*?)`/g, '$1') // inline code
|
|
769
|
+
.replace(/\[(.*?)\]\(.*?\)/g, '$1'); // links
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
function escapeRegex(str) {
|
|
773
|
+
return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
774
|
+
}
|
|
775
|
+
|
|
776
|
+
// ─── Clickable checkboxes ──────────────────────────────────────────────
|
|
777
|
+
|
|
778
|
+
/**
|
|
779
|
+
* Setup clickable checkboxes in rendered markdown.
|
|
780
|
+
* marked.js renders `- [ ]` / `- [x]` as <input type="checkbox" disabled>.
|
|
781
|
+
* We enable them and toggle the source on click.
|
|
782
|
+
*/
|
|
783
|
+
export function setupCheckboxes(container) {
|
|
784
|
+
const rendered = container.querySelector('.preview-rendered');
|
|
785
|
+
if (!rendered) return;
|
|
786
|
+
|
|
787
|
+
// Enable all task list checkboxes (marked renders them disabled)
|
|
788
|
+
rendered.querySelectorAll('input[type="checkbox"][disabled]').forEach(cb => {
|
|
789
|
+
cb.disabled = false;
|
|
790
|
+
cb.classList.add('md-task-checkbox');
|
|
791
|
+
});
|
|
792
|
+
|
|
793
|
+
// Delegate click handling. Re-point the active state to this instance
|
|
794
|
+
// first — toggleCheckboxInSource reads state.content/currentPath.
|
|
795
|
+
const s = state;
|
|
796
|
+
rendered.addEventListener('change', (e) => {
|
|
797
|
+
const cb = e.target;
|
|
798
|
+
if (!cb.classList.contains('md-task-checkbox')) return;
|
|
799
|
+
activateStateInstance(s);
|
|
800
|
+
toggleCheckboxInSource(cb);
|
|
801
|
+
});
|
|
802
|
+
}
|
|
803
|
+
|
|
804
|
+
async function toggleCheckboxInSource(checkbox) {
|
|
805
|
+
const source = state.content;
|
|
806
|
+
if (!source || !state.currentPath) return;
|
|
807
|
+
|
|
808
|
+
const li = checkbox.closest('li');
|
|
809
|
+
if (!li) return;
|
|
810
|
+
|
|
811
|
+
const isNowChecked = checkbox.checked;
|
|
812
|
+
const oldMark = isNowChecked ? '[ ]' : '[x]';
|
|
813
|
+
const newMark = isNowChecked ? '[x]' : '[ ]';
|
|
814
|
+
|
|
815
|
+
let newSource;
|
|
816
|
+
|
|
817
|
+
// Strategy 1: Use source map attributes (precise byte range)
|
|
818
|
+
const srcStart = li.getAttribute('data-source-start');
|
|
819
|
+
const srcEnd = li.getAttribute('data-source-end');
|
|
820
|
+
if (srcStart != null && srcEnd != null) {
|
|
821
|
+
const start = parseInt(srcStart, 10);
|
|
822
|
+
const end = parseInt(srcEnd, 10);
|
|
823
|
+
const liRaw = source.substring(start, end);
|
|
824
|
+
const replaced = liRaw.replace(oldMark, newMark);
|
|
825
|
+
if (replaced === liRaw) {
|
|
826
|
+
checkbox.checked = !isNowChecked;
|
|
827
|
+
return;
|
|
828
|
+
}
|
|
829
|
+
newSource = source.substring(0, start) + replaced + source.substring(end);
|
|
830
|
+
} else {
|
|
831
|
+
// Strategy 2: Fuzzy text matching fallback
|
|
832
|
+
const taskText = li.textContent.trim();
|
|
833
|
+
const lines = source.split('\n');
|
|
834
|
+
let matchIdx = -1;
|
|
835
|
+
for (let i = 0; i < lines.length; i++) {
|
|
836
|
+
const line = lines[i];
|
|
837
|
+
const taskMatch = line.match(/^(\s*(?:[-*+]|\d+\.)\s+)\[([ xX])\]\s*/);
|
|
838
|
+
if (!taskMatch) continue;
|
|
839
|
+
const lineText = line.slice(taskMatch.index + taskMatch[0].length).trim();
|
|
840
|
+
const strippedLineText = stripInlineMarkdown(lineText);
|
|
841
|
+
if (taskText === lineText || taskText === strippedLineText ||
|
|
842
|
+
taskText.startsWith(lineText) || taskText.startsWith(strippedLineText)) {
|
|
843
|
+
const currentMark = taskMatch[2];
|
|
844
|
+
const wasChecked = currentMark === 'x' || currentMark === 'X';
|
|
845
|
+
if (wasChecked !== isNowChecked) {
|
|
846
|
+
matchIdx = i;
|
|
847
|
+
break;
|
|
848
|
+
}
|
|
849
|
+
}
|
|
850
|
+
}
|
|
851
|
+
if (matchIdx === -1) {
|
|
852
|
+
checkbox.checked = !isNowChecked;
|
|
853
|
+
return;
|
|
854
|
+
}
|
|
855
|
+
lines[matchIdx] = lines[matchIdx].replace(oldMark, newMark);
|
|
856
|
+
newSource = lines.join('\n');
|
|
857
|
+
}
|
|
858
|
+
|
|
859
|
+
// Save
|
|
860
|
+
try {
|
|
861
|
+
await writeSource(newSource);
|
|
862
|
+
state.content = newSource;
|
|
863
|
+
} catch (err) {
|
|
864
|
+
console.error('[InlineEdit] Checkbox save failed:', err);
|
|
865
|
+
// Revert on failure
|
|
866
|
+
checkbox.checked = !isNowChecked;
|
|
867
|
+
showToast(saveFailedMsg(err));
|
|
868
|
+
}
|
|
869
|
+
}
|