@ripla/godd-mcp 1.0.4-canary.8 → 1.0.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +89 -94
- package/dist/godd.cjs +349 -233
- package/dist/godd.js +2193 -838
- package/dist/index.js +152 -122
- package/{notes-api → notes/api}/.env.example +6 -1
- package/{notes-api → notes/api}/README.md +6 -5
- package/{notes-api → notes/api}/app/main.py +29 -2
- package/{notes-api → notes/api}/app/routers/auth.py +6 -1
- package/{notes-api → notes/api}/app/routers/issues.py +157 -1
- package/{notes-api → notes/api}/app/routers/pr.py +2 -2
- package/{notes-api → notes/api}/app/services/github_app_token.py +32 -1
- package/{notes-api → notes/api}/app/services/github_issues.py +101 -11
- package/{notes-api → notes/api}/app/services/github_pr.py +6 -1
- package/{notes-api → notes/api}/tests/test_github_app_token.py +49 -1
- package/{notes-api → notes/api}/tests/test_github_service.py +42 -0
- package/notes/api/tests/test_health.py +52 -0
- package/{notes-api → notes/api}/tests/test_issues.py +216 -0
- package/{notes-api → notes/api}/tests/test_pr.py +24 -0
- package/{notes-api → notes/api}/tests/test_pr_chain.py +19 -0
- package/{notes-api → notes/api}/uv.lock +178 -2
- package/notes/app/DESIGN.md +273 -0
- package/{notes-app → notes/app}/README.md +4 -1
- package/notes/app/docker/Dockerfile.e2e +19 -0
- package/notes/app/docker/Dockerfile.prod +15 -0
- package/notes/app/docker/Dockerfile.test +17 -0
- package/notes/app/e2e/file-tree.spec.ts +26 -0
- package/notes/app/e2e/file-view.spec.ts +25 -0
- package/notes/app/e2e/fixtures.ts +28 -0
- package/{notes-app → notes/app}/e2e/global-setup.ts +5 -7
- package/notes/app/e2e/login.spec.ts +25 -0
- package/notes/app/e2e/pages/login-page.ts +29 -0
- package/notes/app/e2e/pages/main-page.ts +29 -0
- package/notes/app/e2e/panel-resize-handle.spec.ts +213 -0
- package/{notes-app → notes/app}/eslint.config.js +9 -0
- package/{notes-app → notes/app}/package.json +0 -3
- package/{notes-app → notes/app}/pnpm-workspace.yaml +1 -0
- package/notes/app/src/components/CommentPanel.test.tsx +194 -0
- package/{notes-app → notes/app}/src/components/CommentPanel.tsx +119 -10
- package/notes/app/src/components/CommentSelectionPopover.tsx +26 -0
- package/notes/app/src/components/EditToolbar.test.tsx +102 -0
- package/{notes-app → notes/app}/src/components/EditToolbar.tsx +5 -54
- package/notes/app/src/components/FileInfoPanel.branch-tab.test.tsx +250 -0
- package/notes/app/src/components/FileInfoPanel.history-tab.test.tsx +146 -0
- package/notes/app/src/components/FileInfoPanel.test.tsx +101 -0
- package/{notes-app → notes/app}/src/components/FileInfoPanel.tsx +135 -20
- package/notes/app/src/components/IssueCommentThread.test.tsx +93 -0
- package/notes/app/src/components/IssueCommentThread.tsx +156 -0
- package/notes/app/src/components/IssueDetailView.test.tsx +1118 -0
- package/notes/app/src/components/IssueDetailView.tsx +476 -0
- package/notes/app/src/components/IssueList.test.tsx +545 -0
- package/notes/app/src/components/IssueList.tsx +374 -0
- package/{notes-app → notes/app}/src/components/LabelSelector.test.tsx +94 -8
- package/notes/app/src/components/LabelSelector.tsx +452 -0
- package/{notes-app → notes/app}/src/components/MarkdownEditor.tsx +140 -2
- package/notes/app/src/components/PanelResizeHandle.test.tsx +481 -0
- package/notes/app/src/components/PanelResizeHandle.tsx +96 -0
- package/{notes-app → notes/app}/src/hooks/useEditSession.test.ts +171 -23
- package/{notes-app → notes/app}/src/hooks/useEditSession.ts +49 -62
- package/notes/app/src/hooks/useMdUpLayout.test.ts +10 -0
- package/notes/app/src/hooks/useMdUpLayout.ts +27 -0
- package/notes/app/src/hooks/usePeriodicSync.test.ts +244 -0
- package/notes/app/src/hooks/usePeriodicSync.ts +83 -0
- package/notes/app/src/hooks/useResizablePanelWidth.test.ts +251 -0
- package/notes/app/src/hooks/useResizablePanelWidth.ts +167 -0
- package/{notes-app → notes/app}/src/lib/api.test.ts +81 -1
- package/{notes-app → notes/app}/src/lib/api.ts +98 -4
- package/notes/app/src/lib/commentAnchor.test.ts +22 -0
- package/notes/app/src/lib/commentAnchor.ts +28 -0
- package/notes/app/src/lib/commentHighlight.test.ts +110 -0
- package/notes/app/src/lib/commentHighlight.ts +44 -0
- package/notes/app/src/lib/github-sync.test.ts +126 -0
- package/notes/app/src/lib/github-sync.ts +89 -0
- package/notes/app/src/lib/issue-list-utils.test.ts +275 -0
- package/notes/app/src/lib/issue-list-utils.ts +135 -0
- package/notes/app/src/lib/issue-mobile-layout.test.ts +17 -0
- package/notes/app/src/lib/issue-mobile-layout.ts +20 -0
- package/notes/app/src/lib/issue-url-sync.test.ts +73 -0
- package/notes/app/src/lib/issue-url-sync.ts +43 -0
- package/notes/app/src/lib/main-page-default-view.test.ts +37 -0
- package/notes/app/src/lib/main-page-default-view.ts +15 -0
- package/notes/app/src/lib/resizable-panel.test.ts +199 -0
- package/notes/app/src/lib/resizable-panel.ts +136 -0
- package/notes/app/src/pages/LoginPage.test.tsx +17 -0
- package/notes/app/src/pages/MainPage.branch-modal.test.tsx +226 -0
- package/notes/app/src/pages/MainPage.issue-sync.test.tsx +145 -0
- package/notes/app/src/pages/MainPage.sidebar.test.tsx +361 -0
- package/{notes-app → notes/app}/src/pages/MainPage.tsx +406 -61
- package/{notes-app → notes/app}/src/pages/SettingsPage.test.tsx +32 -23
- package/{notes-app → notes/app}/src/pages/UserManagementPage.test.tsx +8 -18
- package/notes/app/src/test/mocks.ts +156 -0
- package/notes/app/src/test/render.tsx +27 -0
- package/package.json +3 -5
- package/notes-api/tests/test_health.py +0 -11
- package/notes-app/docker/Dockerfile.e2e +0 -14
- package/notes-app/docker/Dockerfile.prod +0 -12
- package/notes-app/docker/Dockerfile.test +0 -12
- package/notes-app/e2e/file-tree.spec.ts +0 -29
- package/notes-app/e2e/file-view.spec.ts +0 -24
- package/notes-app/e2e/login.spec.ts +0 -32
- package/notes-app/src/components/IssueDetailView.test.tsx +0 -201
- package/notes-app/src/components/IssueDetailView.tsx +0 -318
- package/notes-app/src/components/IssueList.test.tsx +0 -93
- package/notes-app/src/components/IssueList.tsx +0 -169
- package/notes-app/src/components/LabelSelector.tsx +0 -276
- package/notes-app/src/pages/LoginPage.test.tsx +0 -29
- package/stacks/django-vue.yaml +0 -33
- package/stacks/fastapi-react.yaml +0 -160
- package/stacks/flutter-firebase.yaml +0 -58
- package/stacks/nextjs-prisma.yaml +0 -33
- package/stacks/schema.json +0 -144
- package/templates/agents/architect.hbs +0 -183
- package/templates/agents/auto-documenter.hbs +0 -36
- package/templates/agents/backend-developer.hbs +0 -175
- package/templates/agents/code-reviewer.hbs +0 -29
- package/templates/agents/database-developer.hbs +0 -52
- package/templates/agents/debug-specialist.hbs +0 -39
- package/templates/agents/environment-setup.hbs +0 -41
- package/templates/agents/frontend-developer.hbs +0 -45
- package/templates/agents/integration-qa.hbs +0 -26
- package/templates/agents/performance-qa.hbs +0 -24
- package/templates/agents/pr-writer.hbs +0 -53
- package/templates/agents/quality-lead.hbs +0 -26
- package/templates/agents/requirements-analyst.hbs +0 -26
- package/templates/agents/security-analyst.hbs +0 -21
- package/templates/agents/security-reviewer.hbs +0 -20
- package/templates/agents/ssot-updater.hbs +0 -45
- package/templates/agents/technical-writer.hbs +0 -28
- package/templates/agents/unit-test-engineer.hbs +0 -19
- package/templates/agents/user-clone.hbs +0 -43
- package/templates/github-actions/aws/deploy-notes.yml.hbs +0 -117
- package/templates/github-actions/azure/deploy-notes.yml.hbs +0 -78
- package/templates/github-actions/gcp/deploy-notes.yml.hbs +0 -76
- package/templates/github-actions/vercel-railway/deploy-notes.yml.hbs +0 -49
- package/templates/mindsets/agent-stdio.hbs +0 -51
- package/templates/mindsets/dart.hbs +0 -39
- package/templates/mindsets/ddd-clean-architecture.hbs +0 -52
- package/templates/mindsets/electron.hbs +0 -41
- package/templates/mindsets/fastapi.hbs +0 -40
- package/templates/mindsets/flutter.hbs +0 -56
- package/templates/mindsets/kotlin.hbs +0 -46
- package/templates/mindsets/postgresql.hbs +0 -39
- package/templates/mindsets/python.hbs +0 -39
- package/templates/mindsets/react.hbs +0 -51
- package/templates/mindsets/swift.hbs +0 -46
- package/templates/mindsets/terraform.hbs +0 -53
- package/templates/mindsets/typescript.hbs +0 -34
- package/templates/mindsets/vite.hbs +0 -37
- package/templates/notes-compose.yml +0 -82
- package/templates/partials/cogito-geo.hbs +0 -84
- package/templates/partials/cogito-godd.hbs +0 -22
- package/templates/partials/cogito-grammar.hbs +0 -105
- package/templates/partials/cogito-particle.hbs +0 -76
- package/templates/partials/cogito-root-ai.hbs +0 -79
- package/templates/partials/cogito-root-arch.hbs +0 -75
- package/templates/partials/cogito-root-core.hbs +0 -75
- package/templates/partials/cogito-root-daily.hbs +0 -58
- package/templates/partials/cogito-root-dev.hbs +0 -71
- package/templates/partials/cogito-root-devops.hbs +0 -71
- package/templates/partials/cogito-root-framework.hbs +0 -80
- package/templates/partials/cogito-root-lang.hbs +0 -74
- package/templates/partials/godd-core.hbs +0 -50
- package/templates/partials/project-context.hbs +0 -52
- package/templates/partials/ssot-header.hbs +0 -40
- package/templates/partials/ssot-rules.hbs +0 -30
- package/templates/prompts/adr.hbs +0 -39
- package/templates/prompts/agent-dev-workflow.hbs +0 -34
- package/templates/prompts/analyze-report.hbs +0 -36
- package/templates/prompts/auto-documentation.hbs +0 -37
- package/templates/prompts/check.hbs +0 -52
- package/templates/prompts/commit.hbs +0 -96
- package/templates/prompts/dev.hbs +0 -127
- package/templates/prompts/docs-init.hbs +0 -101
- package/templates/prompts/docs-update.hbs +0 -51
- package/templates/prompts/git-workflow.hbs +0 -125
- package/templates/prompts/impact-analysis.hbs +0 -43
- package/templates/prompts/install.hbs +0 -40
- package/templates/prompts/new-branch.hbs +0 -61
- package/templates/prompts/notes-deploy.hbs +0 -138
- package/templates/prompts/pr-analyze.hbs +0 -44
- package/templates/prompts/pr-create.hbs +0 -125
- package/templates/prompts/push-execute.hbs +0 -83
- package/templates/prompts/push.hbs +0 -24
- package/templates/prompts/release-notes.hbs +0 -38
- package/templates/prompts/requirements-to-business-flow.hbs +0 -29
- package/templates/prompts/requirements-to-tickets.hbs +0 -26
- package/templates/prompts/review.hbs +0 -126
- package/templates/prompts/setup.hbs +0 -247
- package/templates/prompts/spec.hbs +0 -35
- package/templates/prompts/specification-to-tickets.hbs +0 -24
- package/templates/prompts/submit.hbs +0 -143
- package/templates/prompts/sync.hbs +0 -63
- package/templates/prompts/test-plan.hbs +0 -56
- package/templates/scripts/aws/terraform-backend-setup.sh.hbs +0 -149
- package/templates/terraform/aws/alb.tf.hbs +0 -58
- package/templates/terraform/aws/backend.tf.hbs +0 -10
- package/templates/terraform/aws/cloudfront.tf.hbs +0 -96
- package/templates/terraform/aws/ecr.tf.hbs +0 -30
- package/templates/terraform/aws/ecs.tf.hbs +0 -142
- package/templates/terraform/aws/iam.tf.hbs +0 -88
- package/templates/terraform/aws/local.tf.hbs +0 -34
- package/templates/terraform/aws/output.tf.hbs +0 -49
- package/templates/terraform/aws/provider.tf.hbs +0 -26
- package/templates/terraform/aws/rds.tf.hbs +0 -38
- package/templates/terraform/aws/s3.tf.hbs +0 -34
- package/templates/terraform/aws/secrets.tf.hbs +0 -53
- package/templates/terraform/aws/security_groups.tf.hbs +0 -81
- package/templates/terraform/aws/vpc.tf.hbs +0 -63
- package/templates/terraform/azure/backend.tf.hbs +0 -8
- package/templates/terraform/azure/iam.tf.hbs +0 -36
- package/templates/terraform/azure/main.tf.hbs +0 -203
- package/templates/terraform/azure/output.tf.hbs +0 -19
- package/templates/terraform/azure/provider.tf.hbs +0 -19
- package/templates/terraform/gcp/backend.tf.hbs +0 -6
- package/templates/terraform/gcp/iam.tf.hbs +0 -47
- package/templates/terraform/gcp/main.tf.hbs +0 -175
- package/templates/terraform/gcp/output.tf.hbs +0 -15
- package/templates/terraform/gcp/provider.tf.hbs +0 -19
- package/templates/terraform/vercel-railway/main.tf.hbs +0 -64
- package/templates/terraform/vercel-railway/output.tf.hbs +0 -11
- /package/{notes-api → notes/api}/.dockerignore +0 -0
- /package/{notes-api → notes/api}/alembic/README +0 -0
- /package/{notes-api → notes/api}/alembic/env.py +0 -0
- /package/{notes-api → notes/api}/alembic/script.py.mako +0 -0
- /package/{notes-api → notes/api}/alembic/versions/001_initial_notes_tables.py +0 -0
- /package/{notes-api → notes/api}/alembic/versions/002_add_performance_indexes.py +0 -0
- /package/{notes-api → notes/api}/alembic/versions/003_add_yjs_documents.py +0 -0
- /package/{notes-api → notes/api}/alembic/versions/004_add_comments.py +0 -0
- /package/{notes-api → notes/api}/alembic/versions/005_add_github_username.py +0 -0
- /package/{notes-api → notes/api}/alembic/versions/006_add_check_constraints.py +0 -0
- /package/{notes-api → notes/api}/alembic.ini +0 -0
- /package/{notes-api → notes/api}/app/__init__.py +0 -0
- /package/{notes-api → notes/api}/app/config.py +0 -0
- /package/{notes-api → notes/api}/app/database.py +0 -0
- /package/{notes-api → notes/api}/app/http_client.py +0 -0
- /package/{notes-api → notes/api}/app/models/__init__.py +0 -0
- /package/{notes-api → notes/api}/app/models/audit.py +0 -0
- /package/{notes-api → notes/api}/app/models/base.py +0 -0
- /package/{notes-api → notes/api}/app/models/collab.py +0 -0
- /package/{notes-api → notes/api}/app/models/comment.py +0 -0
- /package/{notes-api → notes/api}/app/models/session.py +0 -0
- /package/{notes-api → notes/api}/app/models/setting.py +0 -0
- /package/{notes-api → notes/api}/app/models/user.py +0 -0
- /package/{notes-api → notes/api}/app/path_validation.py +0 -0
- /package/{notes-api → notes/api}/app/routers/__init__.py +0 -0
- /package/{notes-api → notes/api}/app/routers/collab.py +0 -0
- /package/{notes-api → notes/api}/app/routers/comments.py +0 -0
- /package/{notes-api → notes/api}/app/routers/files.py +0 -0
- /package/{notes-api → notes/api}/app/routers/settings.py +0 -0
- /package/{notes-api → notes/api}/app/routers/tree.py +0 -0
- /package/{notes-api → notes/api}/app/routers/upload.py +0 -0
- /package/{notes-api → notes/api}/app/routers/users.py +0 -0
- /package/{notes-api → notes/api}/app/schemas/__init__.py +0 -0
- /package/{notes-api → notes/api}/app/schemas/auth.py +0 -0
- /package/{notes-api → notes/api}/app/schemas/user.py +0 -0
- /package/{notes-api → notes/api}/app/security.py +0 -0
- /package/{notes-api → notes/api}/app/services/__init__.py +0 -0
- /package/{notes-api → notes/api}/app/services/audit.py +0 -0
- /package/{notes-api → notes/api}/app/services/business_date.py +0 -0
- /package/{notes-api → notes/api}/app/services/collab.py +0 -0
- /package/{notes-api → notes/api}/app/services/github.py +0 -0
- /package/{notes-api → notes/api}/app/services/pr_chain.py +0 -0
- /package/{notes-api → notes/api}/app/startup.py +0 -0
- /package/{notes-api → notes/api}/docker/Dockerfile +0 -0
- /package/{notes-api → notes/api}/docker/Dockerfile.test +0 -0
- /package/{notes-api → notes/api}/pyproject.toml +0 -0
- /package/{notes-api → notes/api}/tests/conftest.py +0 -0
- /package/{notes-api → notes/api}/tests/test_audit.py +0 -0
- /package/{notes-api → notes/api}/tests/test_auth.py +0 -0
- /package/{notes-api → notes/api}/tests/test_business_date.py +0 -0
- /package/{notes-api → notes/api}/tests/test_collab.py +0 -0
- /package/{notes-api → notes/api}/tests/test_comments.py +0 -0
- /package/{notes-api → notes/api}/tests/test_config_ssl.py +0 -0
- /package/{notes-api → notes/api}/tests/test_config_warnings.py +0 -0
- /package/{notes-api → notes/api}/tests/test_files.py +0 -0
- /package/{notes-api → notes/api}/tests/test_path_validation.py +0 -0
- /package/{notes-api → notes/api}/tests/test_security.py +0 -0
- /package/{notes-api → notes/api}/tests/test_settings.py +0 -0
- /package/{notes-api → notes/api}/tests/test_tree.py +0 -0
- /package/{notes-api → notes/api}/tests/test_upload.py +0 -0
- /package/{notes-api → notes/api}/tests/test_users.py +0 -0
- /package/{notes-app → notes/app}/.dockerignore +0 -0
- /package/{notes-app → notes/app}/index.html +0 -0
- /package/{notes-app → notes/app}/nginx.conf +0 -0
- /package/{notes-app → notes/app}/package-lock.json +0 -0
- /package/{notes-app → notes/app}/playwright.config.ts +0 -0
- /package/{notes-app → notes/app}/pnpm-lock.yaml +0 -0
- /package/{notes-app → notes/app}/src/App.tsx +0 -0
- /package/{notes-app → notes/app}/src/components/BranchSelectorModal.tsx +0 -0
- /package/{notes-app → notes/app}/src/components/CodeBlockView.test.tsx +0 -0
- /package/{notes-app → notes/app}/src/components/CodeBlockView.tsx +0 -0
- /package/{notes-app → notes/app}/src/components/CollabStatus.tsx +0 -0
- /package/{notes-app → notes/app}/src/components/DiffPreview.tsx +0 -0
- /package/{notes-app → notes/app}/src/components/DrawioEditor.tsx +0 -0
- /package/{notes-app → notes/app}/src/components/DrawioViewer.tsx +0 -0
- /package/{notes-app → notes/app}/src/components/ErrorBoundary.test.tsx +0 -0
- /package/{notes-app → notes/app}/src/components/ErrorBoundary.tsx +0 -0
- /package/{notes-app → notes/app}/src/components/FileContentView.tsx +0 -0
- /package/{notes-app → notes/app}/src/components/FileContextMenu.test.tsx +0 -0
- /package/{notes-app → notes/app}/src/components/FileContextMenu.tsx +0 -0
- /package/{notes-app → notes/app}/src/components/FileDialogs.test.tsx +0 -0
- /package/{notes-app → notes/app}/src/components/FileDialogs.tsx +0 -0
- /package/{notes-app → notes/app}/src/components/SaveStatusIndicator.test.tsx +0 -0
- /package/{notes-app → notes/app}/src/components/SaveStatusIndicator.tsx +0 -0
- /package/{notes-app → notes/app}/src/components/Skeleton.tsx +0 -0
- /package/{notes-app → notes/app}/src/components/SpreadsheetEditor.test.tsx +0 -0
- /package/{notes-app → notes/app}/src/components/SpreadsheetEditor.tsx +0 -0
- /package/{notes-app → notes/app}/src/components/TextEditor.tsx +0 -0
- /package/{notes-app → notes/app}/src/components/ToastContainer.tsx +0 -0
- /package/{notes-app → notes/app}/src/components/TreeNode.tsx +0 -0
- /package/{notes-app → notes/app}/src/contexts/AuthContext.test.tsx +0 -0
- /package/{notes-app → notes/app}/src/contexts/AuthContext.tsx +0 -0
- /package/{notes-app → notes/app}/src/contexts/ToastContext.test.ts +0 -0
- /package/{notes-app → notes/app}/src/contexts/ToastContext.tsx +0 -0
- /package/{notes-app → notes/app}/src/hooks/useAutoSave.test.ts +0 -0
- /package/{notes-app → notes/app}/src/hooks/useAutoSave.ts +0 -0
- /package/{notes-app → notes/app}/src/hooks/useCollaboration.ts +0 -0
- /package/{notes-app → notes/app}/src/hooks/useComments.ts +0 -0
- /package/{notes-app → notes/app}/src/hooks/useYText.ts +0 -0
- /package/{notes-app → notes/app}/src/lib/branch-diff.test.ts +0 -0
- /package/{notes-app → notes/app}/src/lib/branch-diff.ts +0 -0
- /package/{notes-app → notes/app}/src/lib/created-file.test.ts +0 -0
- /package/{notes-app → notes/app}/src/lib/created-file.ts +0 -0
- /package/{notes-app → notes/app}/src/lib/csv-utils.test.ts +0 -0
- /package/{notes-app → notes/app}/src/lib/csv-utils.ts +0 -0
- /package/{notes-app → notes/app}/src/lib/deepLinkPathToSearch.test.ts +0 -0
- /package/{notes-app → notes/app}/src/lib/deepLinkPathToSearch.ts +0 -0
- /package/{notes-app → notes/app}/src/lib/draft-storage.test.ts +0 -0
- /package/{notes-app → notes/app}/src/lib/draft-storage.ts +0 -0
- /package/{notes-app → notes/app}/src/lib/mermaid-render.ts +0 -0
- /package/{notes-app → notes/app}/src/lib/postLoginRedirect.test.ts +0 -0
- /package/{notes-app → notes/app}/src/lib/postLoginRedirect.ts +0 -0
- /package/{notes-app → notes/app}/src/lib/tree-utils.ts +0 -0
- /package/{notes-app → notes/app}/src/lib/user-colors.ts +0 -0
- /package/{notes-app → notes/app}/src/lib/xlsx-utils.test.ts +0 -0
- /package/{notes-app → notes/app}/src/lib/xlsx-utils.ts +0 -0
- /package/{notes-app → notes/app}/src/main.tsx +0 -0
- /package/{notes-app → notes/app}/src/pages/LoginPage.tsx +0 -0
- /package/{notes-app → notes/app}/src/pages/MainPage.insertChildEntry.test.ts +0 -0
- /package/{notes-app → notes/app}/src/pages/SettingsPage.tsx +0 -0
- /package/{notes-app → notes/app}/src/pages/UserManagementPage.tsx +0 -0
- /package/{notes-app → notes/app}/src/styles/globals.css +0 -0
- /package/{notes-app → notes/app}/src/types.ts +0 -0
- /package/{notes-app → notes/app}/src/vite-env.d.ts +0 -0
- /package/{notes-app → notes/app}/tsconfig.json +0 -0
- /package/{notes-app → notes/app}/tsconfig.node.json +0 -0
- /package/{notes-app → notes/app}/vite.config.js +0 -0
- /package/{notes-app → notes/app}/vite.config.ts +0 -0
- /package/{notes-app → notes/app}/vitest.config.ts +0 -0
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
import { useCallback, useEffect, useRef, useState } from "react";
|
|
2
|
+
import {
|
|
3
|
+
clampPanelWidth,
|
|
4
|
+
computeResizedWidth,
|
|
5
|
+
isRtlContext,
|
|
6
|
+
loadStoredPanelWidth,
|
|
7
|
+
PANEL_WIDTH_SAVE_DEBOUNCE_MS,
|
|
8
|
+
type ResizeEdge,
|
|
9
|
+
saveStoredPanelWidth,
|
|
10
|
+
} from "@/lib/resizable-panel";
|
|
11
|
+
|
|
12
|
+
type Options = {
|
|
13
|
+
storageKey: string;
|
|
14
|
+
defaultWidth: number;
|
|
15
|
+
minWidth: number;
|
|
16
|
+
maxWidth: number;
|
|
17
|
+
edge: ResizeEdge;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
type ActiveDrag = {
|
|
21
|
+
handle: HTMLElement;
|
|
22
|
+
pointerId: number;
|
|
23
|
+
handleMove: (event: PointerEvent) => void;
|
|
24
|
+
handleUp: (event: PointerEvent) => void;
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
export function useResizablePanelWidth(options: Options) {
|
|
28
|
+
const { storageKey, defaultWidth, minWidth, maxWidth, edge } = options;
|
|
29
|
+
const [width, setWidth] = useState(() =>
|
|
30
|
+
loadStoredPanelWidth(storageKey, defaultWidth, minWidth, maxWidth),
|
|
31
|
+
);
|
|
32
|
+
const widthRef = useRef(width);
|
|
33
|
+
const storageKeyRef = useRef(storageKey);
|
|
34
|
+
const saveTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
35
|
+
const skipInitialSaveRef = useRef(true);
|
|
36
|
+
const lastSavedWidthRef = useRef<number | null>(null);
|
|
37
|
+
const activeDragRef = useRef<ActiveDrag | null>(null);
|
|
38
|
+
|
|
39
|
+
useEffect(() => {
|
|
40
|
+
storageKeyRef.current = storageKey;
|
|
41
|
+
}, [storageKey]);
|
|
42
|
+
|
|
43
|
+
useEffect(() => {
|
|
44
|
+
widthRef.current = width;
|
|
45
|
+
}, [width]);
|
|
46
|
+
|
|
47
|
+
const cancelSaveTimer = useCallback(() => {
|
|
48
|
+
if (saveTimerRef.current != null) {
|
|
49
|
+
clearTimeout(saveTimerRef.current);
|
|
50
|
+
saveTimerRef.current = null;
|
|
51
|
+
}
|
|
52
|
+
}, []);
|
|
53
|
+
|
|
54
|
+
const persistWidth = useCallback((nextWidth: number) => {
|
|
55
|
+
if (lastSavedWidthRef.current === nextWidth) return;
|
|
56
|
+
lastSavedWidthRef.current = nextWidth;
|
|
57
|
+
saveStoredPanelWidth(storageKeyRef.current, nextWidth);
|
|
58
|
+
}, []);
|
|
59
|
+
|
|
60
|
+
const flushSave = useCallback(() => {
|
|
61
|
+
cancelSaveTimer();
|
|
62
|
+
persistWidth(widthRef.current);
|
|
63
|
+
}, [cancelSaveTimer, persistWidth]);
|
|
64
|
+
|
|
65
|
+
useEffect(() => {
|
|
66
|
+
if (skipInitialSaveRef.current) {
|
|
67
|
+
skipInitialSaveRef.current = false;
|
|
68
|
+
lastSavedWidthRef.current = width;
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
cancelSaveTimer();
|
|
72
|
+
saveTimerRef.current = setTimeout(() => {
|
|
73
|
+
persistWidth(width);
|
|
74
|
+
saveTimerRef.current = null;
|
|
75
|
+
}, PANEL_WIDTH_SAVE_DEBOUNCE_MS);
|
|
76
|
+
return cancelSaveTimer;
|
|
77
|
+
}, [storageKey, width, cancelSaveTimer, persistWidth]);
|
|
78
|
+
|
|
79
|
+
useEffect(() => {
|
|
80
|
+
const handleBeforeUnload = () => {
|
|
81
|
+
flushSave();
|
|
82
|
+
};
|
|
83
|
+
window.addEventListener("beforeunload", handleBeforeUnload);
|
|
84
|
+
return () => {
|
|
85
|
+
window.removeEventListener("beforeunload", handleBeforeUnload);
|
|
86
|
+
flushSave();
|
|
87
|
+
|
|
88
|
+
const drag = activeDragRef.current;
|
|
89
|
+
if (drag == null) return;
|
|
90
|
+
|
|
91
|
+
try {
|
|
92
|
+
drag.handle.releasePointerCapture(drag.pointerId);
|
|
93
|
+
} catch {
|
|
94
|
+
// pointer already released
|
|
95
|
+
}
|
|
96
|
+
document.body.style.cursor = "";
|
|
97
|
+
document.body.style.userSelect = "";
|
|
98
|
+
window.removeEventListener("pointermove", drag.handleMove);
|
|
99
|
+
window.removeEventListener("pointerup", drag.handleUp);
|
|
100
|
+
window.removeEventListener("pointercancel", drag.handleUp);
|
|
101
|
+
activeDragRef.current = null;
|
|
102
|
+
};
|
|
103
|
+
}, [flushSave]);
|
|
104
|
+
|
|
105
|
+
const onResizePointerDown = useCallback(
|
|
106
|
+
(event: React.PointerEvent<HTMLElement>) => {
|
|
107
|
+
event.preventDefault();
|
|
108
|
+
const handle = event.currentTarget;
|
|
109
|
+
handle.setPointerCapture(event.pointerId);
|
|
110
|
+
|
|
111
|
+
const startX = event.clientX;
|
|
112
|
+
const startWidth = widthRef.current;
|
|
113
|
+
// 書字方向はドラッグ中に変わらないため pointerdown 時に一度だけ判定する。
|
|
114
|
+
const rtl = isRtlContext(handle);
|
|
115
|
+
|
|
116
|
+
const handleMove = (moveEvent: PointerEvent) => {
|
|
117
|
+
const next = computeResizedWidth(
|
|
118
|
+
startWidth,
|
|
119
|
+
startX,
|
|
120
|
+
moveEvent.clientX,
|
|
121
|
+
edge,
|
|
122
|
+
minWidth,
|
|
123
|
+
maxWidth,
|
|
124
|
+
rtl,
|
|
125
|
+
);
|
|
126
|
+
widthRef.current = next;
|
|
127
|
+
setWidth(next);
|
|
128
|
+
};
|
|
129
|
+
|
|
130
|
+
const handleUp = (upEvent: PointerEvent) => {
|
|
131
|
+
handle.releasePointerCapture(upEvent.pointerId);
|
|
132
|
+
document.body.style.cursor = "";
|
|
133
|
+
document.body.style.userSelect = "";
|
|
134
|
+
window.removeEventListener("pointermove", handleMove);
|
|
135
|
+
window.removeEventListener("pointerup", handleUp);
|
|
136
|
+
window.removeEventListener("pointercancel", handleUp);
|
|
137
|
+
activeDragRef.current = null;
|
|
138
|
+
flushSave();
|
|
139
|
+
};
|
|
140
|
+
|
|
141
|
+
activeDragRef.current = {
|
|
142
|
+
handle,
|
|
143
|
+
pointerId: event.pointerId,
|
|
144
|
+
handleMove,
|
|
145
|
+
handleUp,
|
|
146
|
+
};
|
|
147
|
+
|
|
148
|
+
document.body.style.cursor = "col-resize";
|
|
149
|
+
document.body.style.userSelect = "none";
|
|
150
|
+
window.addEventListener("pointermove", handleMove);
|
|
151
|
+
window.addEventListener("pointerup", handleUp);
|
|
152
|
+
window.addEventListener("pointercancel", handleUp);
|
|
153
|
+
},
|
|
154
|
+
[edge, maxWidth, minWidth, flushSave],
|
|
155
|
+
);
|
|
156
|
+
|
|
157
|
+
const applyWidth = useCallback(
|
|
158
|
+
(next: number) => {
|
|
159
|
+
const clamped = clampPanelWidth(next, minWidth, maxWidth);
|
|
160
|
+
widthRef.current = clamped;
|
|
161
|
+
setWidth(clamped);
|
|
162
|
+
},
|
|
163
|
+
[maxWidth, minWidth],
|
|
164
|
+
);
|
|
165
|
+
|
|
166
|
+
return { width, onResizePointerDown, applyWidth };
|
|
167
|
+
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
2
|
-
import { getErrorMessage, isAbortError, getToken, setToken, clearToken, encodePath } from "./api";
|
|
2
|
+
import { getErrorMessage, isAbortError, getToken, setToken, clearToken, encodePath, buildRequestSignal } from "./api";
|
|
3
3
|
|
|
4
4
|
describe("getErrorMessage", () => {
|
|
5
5
|
it("Error インスタンスからメッセージを取得する", () => {
|
|
@@ -229,6 +229,86 @@ describe("fetchSessionsApi", () => {
|
|
|
229
229
|
});
|
|
230
230
|
});
|
|
231
231
|
|
|
232
|
+
describe("apiFetch signal 合成(デフォルトタイムアウト保持)", () => {
|
|
233
|
+
beforeEach(() => {
|
|
234
|
+
vi.restoreAllMocks();
|
|
235
|
+
localStorage.clear();
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
it("caller signal を渡してもキャンセルが forward 先に伝播する", async () => {
|
|
239
|
+
const { fetchHistoryApi, setToken } = await import("./api");
|
|
240
|
+
setToken("test-token");
|
|
241
|
+
let captured: AbortSignal | null = null;
|
|
242
|
+
vi.spyOn(globalThis, "fetch").mockImplementation(
|
|
243
|
+
(_input: RequestInfo | URL, init?: RequestInit) => {
|
|
244
|
+
captured = init?.signal ?? null;
|
|
245
|
+
return Promise.resolve(
|
|
246
|
+
new Response(JSON.stringify([]), {
|
|
247
|
+
status: 200,
|
|
248
|
+
headers: { "Content-Type": "application/json" },
|
|
249
|
+
}),
|
|
250
|
+
);
|
|
251
|
+
},
|
|
252
|
+
);
|
|
253
|
+
|
|
254
|
+
const controller = new AbortController();
|
|
255
|
+
await fetchHistoryApi("docs/a.md", { signal: controller.signal });
|
|
256
|
+
|
|
257
|
+
expect(captured).not.toBeNull();
|
|
258
|
+
// タイムアウトと合成済みのため caller signal とは別インスタンス
|
|
259
|
+
expect(captured).not.toBe(controller.signal);
|
|
260
|
+
expect(captured!.aborted).toBe(false);
|
|
261
|
+
controller.abort();
|
|
262
|
+
expect(captured!.aborted).toBe(true);
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
it("caller signal なしでもタイムアウト signal が forward される", async () => {
|
|
266
|
+
const { fetchHistoryApi, setToken } = await import("./api");
|
|
267
|
+
setToken("test-token");
|
|
268
|
+
let captured: AbortSignal | null = null;
|
|
269
|
+
vi.spyOn(globalThis, "fetch").mockImplementation(
|
|
270
|
+
(_input: RequestInfo | URL, init?: RequestInit) => {
|
|
271
|
+
captured = init?.signal ?? null;
|
|
272
|
+
return Promise.resolve(
|
|
273
|
+
new Response(JSON.stringify([]), {
|
|
274
|
+
status: 200,
|
|
275
|
+
headers: { "Content-Type": "application/json" },
|
|
276
|
+
}),
|
|
277
|
+
);
|
|
278
|
+
},
|
|
279
|
+
);
|
|
280
|
+
|
|
281
|
+
await fetchHistoryApi("docs/a.md");
|
|
282
|
+
expect(captured).toBeInstanceOf(AbortSignal);
|
|
283
|
+
});
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
describe("buildRequestSignal", () => {
|
|
287
|
+
it("signal なしのときタイムアウト signal を返す", () => {
|
|
288
|
+
const signal = buildRequestSignal(undefined, 10_000);
|
|
289
|
+
expect(signal).toBeInstanceOf(AbortSignal);
|
|
290
|
+
expect(signal.aborted).toBe(false);
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
it("caller signal を合成し、caller の abort が伝播する", () => {
|
|
294
|
+
const controller = new AbortController();
|
|
295
|
+
const signal = buildRequestSignal(controller.signal, 10_000);
|
|
296
|
+
expect(signal).not.toBe(controller.signal);
|
|
297
|
+
expect(signal.aborted).toBe(false);
|
|
298
|
+
controller.abort();
|
|
299
|
+
expect(signal.aborted).toBe(true);
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
it("タイムアウトが経過すると合成 signal が abort する", async () => {
|
|
303
|
+
const controller = new AbortController();
|
|
304
|
+
const signal = buildRequestSignal(controller.signal, 10);
|
|
305
|
+
expect(signal.aborted).toBe(false);
|
|
306
|
+
await new Promise((resolve) => setTimeout(resolve, 60));
|
|
307
|
+
expect(signal.aborted).toBe(true);
|
|
308
|
+
expect(controller.signal.aborted).toBe(false); // caller 側は abort されない
|
|
309
|
+
});
|
|
310
|
+
});
|
|
311
|
+
|
|
232
312
|
describe("encodePath", () => {
|
|
233
313
|
it("encodes special characters in path segments", () => {
|
|
234
314
|
expect(encodePath("docs/my file.md")).toBe("docs/my%20file.md");
|
|
@@ -34,6 +34,18 @@ async function refreshToken(): Promise<string | null> {
|
|
|
34
34
|
return newToken;
|
|
35
35
|
}
|
|
36
36
|
|
|
37
|
+
/**
|
|
38
|
+
* caller が signal を渡しても、デフォルトタイムアウトを失わないよう合成する。
|
|
39
|
+
* caller のキャンセル / タイムアウトのどちらか一方が abort すれば合成 signal も abort される。
|
|
40
|
+
*/
|
|
41
|
+
export function buildRequestSignal(
|
|
42
|
+
callerSignal: AbortSignal | null | undefined,
|
|
43
|
+
timeoutMs: number,
|
|
44
|
+
): AbortSignal {
|
|
45
|
+
const timeoutSignal = AbortSignal.timeout(timeoutMs);
|
|
46
|
+
return callerSignal ? AbortSignal.any([callerSignal, timeoutSignal]) : timeoutSignal;
|
|
47
|
+
}
|
|
48
|
+
|
|
37
49
|
export async function apiFetch(
|
|
38
50
|
path: string,
|
|
39
51
|
init: RequestInit = {},
|
|
@@ -44,7 +56,7 @@ export async function apiFetch(
|
|
|
44
56
|
headers.set("Authorization", `Bearer ${token}`);
|
|
45
57
|
}
|
|
46
58
|
|
|
47
|
-
const signal = init.signal
|
|
59
|
+
const signal = buildRequestSignal(init.signal, DEFAULT_TIMEOUT_MS);
|
|
48
60
|
|
|
49
61
|
let res = await fetch(`${API_BASE}${path}`, {
|
|
50
62
|
...init,
|
|
@@ -232,8 +244,11 @@ export type CommitEntry = {
|
|
|
232
244
|
date: string;
|
|
233
245
|
};
|
|
234
246
|
|
|
235
|
-
export async function fetchHistoryApi(
|
|
236
|
-
|
|
247
|
+
export async function fetchHistoryApi(
|
|
248
|
+
path: string,
|
|
249
|
+
options: { signal?: AbortSignal } = {},
|
|
250
|
+
): Promise<CommitEntry[]> {
|
|
251
|
+
const res = await apiFetch(`/history/${encodePath(path)}`, { signal: options.signal });
|
|
237
252
|
if (!res.ok) {
|
|
238
253
|
const body = await res.json().catch(() => null);
|
|
239
254
|
throw new Error(formatApiDetail(body, `Failed to fetch history: ${res.status}`));
|
|
@@ -437,6 +452,7 @@ export async function fetchProposalsApi(): Promise<Proposal[]> {
|
|
|
437
452
|
export type IssueLabel = {
|
|
438
453
|
name: string;
|
|
439
454
|
color: string;
|
|
455
|
+
description?: string;
|
|
440
456
|
};
|
|
441
457
|
|
|
442
458
|
export type IssueSummary = {
|
|
@@ -470,11 +486,19 @@ export type IssueUpdateInput = {
|
|
|
470
486
|
};
|
|
471
487
|
|
|
472
488
|
export async function listIssuesApi(
|
|
473
|
-
params: {
|
|
489
|
+
params: {
|
|
490
|
+
state?: string;
|
|
491
|
+
labels?: string;
|
|
492
|
+
assignee?: string;
|
|
493
|
+
page?: number;
|
|
494
|
+
per_page?: number;
|
|
495
|
+
signal?: AbortSignal;
|
|
496
|
+
} = {},
|
|
474
497
|
): Promise<{ issues: IssueSummary[] }> {
|
|
475
498
|
const q = new URLSearchParams();
|
|
476
499
|
if (params.state) q.set("state", params.state);
|
|
477
500
|
if (params.labels) q.set("labels", params.labels);
|
|
501
|
+
if (params.assignee) q.set("assignee", params.assignee);
|
|
478
502
|
if (params.page) q.set("page", String(params.page));
|
|
479
503
|
if (params.per_page) q.set("per_page", String(params.per_page));
|
|
480
504
|
const qs = q.toString();
|
|
@@ -524,6 +548,42 @@ export async function updateIssueApi(
|
|
|
524
548
|
return res.json();
|
|
525
549
|
}
|
|
526
550
|
|
|
551
|
+
export type IssueComment = {
|
|
552
|
+
id: number;
|
|
553
|
+
body: string;
|
|
554
|
+
author: string;
|
|
555
|
+
createdAt: string;
|
|
556
|
+
updatedAt: string;
|
|
557
|
+
url: string;
|
|
558
|
+
};
|
|
559
|
+
|
|
560
|
+
export async function listIssueCommentsApi(
|
|
561
|
+
number: number,
|
|
562
|
+
): Promise<{ comments: IssueComment[] }> {
|
|
563
|
+
const res = await apiFetch(`/issues/${number}/comments`);
|
|
564
|
+
if (!res.ok) {
|
|
565
|
+
const body = await res.json().catch(() => null);
|
|
566
|
+
throw new Error(formatApiDetail(body, `Failed to list issue comments: ${res.status}`));
|
|
567
|
+
}
|
|
568
|
+
return res.json();
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
export async function createIssueCommentApi(
|
|
572
|
+
number: number,
|
|
573
|
+
body: string,
|
|
574
|
+
): Promise<IssueComment> {
|
|
575
|
+
const res = await apiFetch(`/issues/${number}/comments`, {
|
|
576
|
+
method: "POST",
|
|
577
|
+
headers: { "Content-Type": "application/json" },
|
|
578
|
+
body: JSON.stringify({ body }),
|
|
579
|
+
});
|
|
580
|
+
if (!res.ok) {
|
|
581
|
+
const resBody = await res.json().catch(() => null);
|
|
582
|
+
throw new Error(formatApiDetail(resBody, `Failed to post issue comment: ${res.status}`));
|
|
583
|
+
}
|
|
584
|
+
return res.json();
|
|
585
|
+
}
|
|
586
|
+
|
|
527
587
|
export async function listIssueLabelsApi(): Promise<{ labels: IssueLabel[] }> {
|
|
528
588
|
const res = await apiFetch("/issues/labels");
|
|
529
589
|
if (!res.ok) {
|
|
@@ -552,6 +612,40 @@ export async function createLabelApi(input: LabelCreateInput): Promise<IssueLabe
|
|
|
552
612
|
return res.json();
|
|
553
613
|
}
|
|
554
614
|
|
|
615
|
+
export type LabelUpdateInput = {
|
|
616
|
+
new_name?: string;
|
|
617
|
+
color?: string;
|
|
618
|
+
description?: string;
|
|
619
|
+
};
|
|
620
|
+
|
|
621
|
+
function labelPath(name: string): string {
|
|
622
|
+
return `/issues/labels/${encodeURIComponent(name)}`;
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
export async function updateLabelApi(
|
|
626
|
+
name: string,
|
|
627
|
+
input: LabelUpdateInput,
|
|
628
|
+
): Promise<IssueLabel> {
|
|
629
|
+
const res = await apiFetch(labelPath(name), {
|
|
630
|
+
method: "PATCH",
|
|
631
|
+
headers: { "Content-Type": "application/json" },
|
|
632
|
+
body: JSON.stringify(input),
|
|
633
|
+
});
|
|
634
|
+
if (!res.ok) {
|
|
635
|
+
const body = await res.json().catch(() => null);
|
|
636
|
+
throw new Error(formatApiDetail(body, `Failed to update label: ${res.status}`));
|
|
637
|
+
}
|
|
638
|
+
return res.json();
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
export async function deleteLabelApi(name: string): Promise<void> {
|
|
642
|
+
const res = await apiFetch(labelPath(name), { method: "DELETE" });
|
|
643
|
+
if (!res.ok) {
|
|
644
|
+
const body = await res.json().catch(() => null);
|
|
645
|
+
throw new Error(formatApiDetail(body, `Failed to delete label: ${res.status}`));
|
|
646
|
+
}
|
|
647
|
+
}
|
|
648
|
+
|
|
555
649
|
// ---------------------------------------------------------------------------
|
|
556
650
|
// Comments
|
|
557
651
|
// ---------------------------------------------------------------------------
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { anchorFromPlainTextSelection } from "./commentAnchor";
|
|
3
|
+
|
|
4
|
+
describe("anchorFromPlainTextSelection", () => {
|
|
5
|
+
it("returns anchor with positions when selection exists in full text", () => {
|
|
6
|
+
const full = "Hello world";
|
|
7
|
+
const result = anchorFromPlainTextSelection(full, "world");
|
|
8
|
+
expect(result).toEqual({
|
|
9
|
+
anchor_text: "world",
|
|
10
|
+
position_from: 6,
|
|
11
|
+
position_to: 11,
|
|
12
|
+
});
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
it("returns null for empty selection", () => {
|
|
16
|
+
expect(anchorFromPlainTextSelection("Hello", " ")).toBeNull();
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it("returns null when selection is not found in full text", () => {
|
|
20
|
+
expect(anchorFromPlainTextSelection("Hello", "missing")).toBeNull();
|
|
21
|
+
});
|
|
22
|
+
});
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
export type CommentAnchor = {
|
|
2
|
+
anchor_text: string;
|
|
3
|
+
position_from: number;
|
|
4
|
+
position_to: number;
|
|
5
|
+
};
|
|
6
|
+
|
|
7
|
+
export function anchorFromPlainTextSelection(
|
|
8
|
+
fullText: string,
|
|
9
|
+
selectedText: string,
|
|
10
|
+
): CommentAnchor | null {
|
|
11
|
+
const anchor_text = selectedText.trim();
|
|
12
|
+
if (!anchor_text) return null;
|
|
13
|
+
|
|
14
|
+
const position_from = fullText.indexOf(anchor_text);
|
|
15
|
+
if (position_from === -1) return null;
|
|
16
|
+
|
|
17
|
+
return {
|
|
18
|
+
anchor_text,
|
|
19
|
+
position_from,
|
|
20
|
+
position_to: position_from + anchor_text.length,
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function getWindowSelectionAnchor(fullText: string): CommentAnchor | null {
|
|
25
|
+
const selection = window.getSelection();
|
|
26
|
+
if (!selection || selection.isCollapsed) return null;
|
|
27
|
+
return anchorFromPlainTextSelection(fullText, selection.toString());
|
|
28
|
+
}
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { Schema } from "@tiptap/pm/model";
|
|
3
|
+
import { buildHighlightDecorations, textOffsetToPos } from "@/lib/commentHighlight";
|
|
4
|
+
import type { CommentAnchor } from "@/lib/commentAnchor";
|
|
5
|
+
|
|
6
|
+
const schema = new Schema({
|
|
7
|
+
nodes: {
|
|
8
|
+
doc: { content: "block+" },
|
|
9
|
+
paragraph: { content: "inline*", group: "block" },
|
|
10
|
+
text: { group: "inline" },
|
|
11
|
+
},
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
function paragraph(text: string) {
|
|
15
|
+
return schema.node("paragraph", null, text ? [schema.text(text)] : undefined);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function docFromParagraphs(...lines: string[]) {
|
|
19
|
+
return schema.node("doc", null, lines.map(paragraph));
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function plainText(doc: ReturnType<typeof docFromParagraphs>): string {
|
|
23
|
+
return doc.textBetween(0, doc.content.size, "\n");
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
describe("textOffsetToPos", () => {
|
|
27
|
+
it("単一段落内の offset を ProseMirror 位置に変換する", () => {
|
|
28
|
+
const doc = docFromParagraphs("Hello world");
|
|
29
|
+
const text = plainText(doc);
|
|
30
|
+
|
|
31
|
+
expect(textOffsetToPos(doc, 0)).toBe(0);
|
|
32
|
+
expect(doc.textBetween(0, textOffsetToPos(doc, 6)!, "\n")).toBe("Hello ");
|
|
33
|
+
const endPos = textOffsetToPos(doc, text.length);
|
|
34
|
+
expect(endPos).not.toBeNull();
|
|
35
|
+
expect(doc.textBetween(0, endPos!, "\n")).toBe(text);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it("段落境界の \\n を plain-text offset に含める", () => {
|
|
39
|
+
const doc = docFromParagraphs("Line one", "Line two");
|
|
40
|
+
const text = plainText(doc);
|
|
41
|
+
const secondLineStart = text.indexOf("Line two");
|
|
42
|
+
|
|
43
|
+
expect(secondLineStart).toBeGreaterThan(0);
|
|
44
|
+
expect(text[secondLineStart - 1]).toBe("\n");
|
|
45
|
+
expect(textOffsetToPos(doc, secondLineStart)).not.toBeNull();
|
|
46
|
+
expect(
|
|
47
|
+
doc.textBetween(0, textOffsetToPos(doc, secondLineStart)!, "\n"),
|
|
48
|
+
).toBe(text.slice(0, secondLineStart));
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it("範囲外 offset は null を返す", () => {
|
|
52
|
+
const doc = docFromParagraphs("Hello");
|
|
53
|
+
expect(textOffsetToPos(doc, -1)).toBeNull();
|
|
54
|
+
expect(textOffsetToPos(doc, plainText(doc).length + 1)).toBeNull();
|
|
55
|
+
});
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
describe("buildHighlightDecorations", () => {
|
|
59
|
+
it("有効なアンカー範囲に decoration を付与する", () => {
|
|
60
|
+
const doc = docFromParagraphs("Hello world");
|
|
61
|
+
const highlights: CommentAnchor[] = [
|
|
62
|
+
{
|
|
63
|
+
anchor_text: "world",
|
|
64
|
+
position_from: 6,
|
|
65
|
+
position_to: 11,
|
|
66
|
+
},
|
|
67
|
+
];
|
|
68
|
+
|
|
69
|
+
const from = textOffsetToPos(doc, highlights[0].position_from);
|
|
70
|
+
const to = textOffsetToPos(doc, highlights[0].position_to);
|
|
71
|
+
expect(doc.textBetween(from!, to!, "\n")).toBe("world");
|
|
72
|
+
|
|
73
|
+
const set = buildHighlightDecorations(doc, highlights);
|
|
74
|
+
expect(set.find(0, doc.content.size).length).toBe(1);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it("2 段落目のアンカーを正しい位置にハイライトする", () => {
|
|
78
|
+
const doc = docFromParagraphs("First paragraph", "Second target");
|
|
79
|
+
const text = plainText(doc);
|
|
80
|
+
const from = text.indexOf("target");
|
|
81
|
+
const to = from + "target".length;
|
|
82
|
+
|
|
83
|
+
const startPos = textOffsetToPos(doc, from);
|
|
84
|
+
const endPos = textOffsetToPos(doc, to);
|
|
85
|
+
expect(doc.textBetween(startPos!, endPos!, "\n")).toBe("target");
|
|
86
|
+
|
|
87
|
+
const set = buildHighlightDecorations(doc, [
|
|
88
|
+
{
|
|
89
|
+
anchor_text: "target",
|
|
90
|
+
position_from: from,
|
|
91
|
+
position_to: to,
|
|
92
|
+
},
|
|
93
|
+
]);
|
|
94
|
+
|
|
95
|
+
expect(set.find(0, doc.content.size).length).toBe(1);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it("無効な offset は decoration を作らない", () => {
|
|
99
|
+
const doc = docFromParagraphs("Hello");
|
|
100
|
+
const set = buildHighlightDecorations(doc, [
|
|
101
|
+
{
|
|
102
|
+
anchor_text: "missing",
|
|
103
|
+
position_from: 100,
|
|
104
|
+
position_to: 107,
|
|
105
|
+
},
|
|
106
|
+
]);
|
|
107
|
+
|
|
108
|
+
expect(set.find(0, doc.content.size).length).toBe(0);
|
|
109
|
+
});
|
|
110
|
+
});
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { Decoration, DecorationSet } from "@tiptap/pm/view";
|
|
2
|
+
import type { Node as ProseMirrorNode } from "@tiptap/pm/model";
|
|
3
|
+
import type { CommentAnchor } from "@/lib/commentAnchor";
|
|
4
|
+
|
|
5
|
+
const PLAIN_TEXT_BLOCK_SEPARATOR = "\n";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Maps a plain-text offset (same convention as `doc.textBetween(0, pos, "\\n")`)
|
|
9
|
+
* to a ProseMirror document position.
|
|
10
|
+
*/
|
|
11
|
+
export function textOffsetToPos(doc: ProseMirrorNode, targetOffset: number): number | null {
|
|
12
|
+
if (targetOffset < 0) return null;
|
|
13
|
+
|
|
14
|
+
const maxPos = doc.content.size;
|
|
15
|
+
for (let pos = 0; pos <= maxPos; pos++) {
|
|
16
|
+
if (doc.textBetween(0, pos, PLAIN_TEXT_BLOCK_SEPARATOR).length === targetOffset) {
|
|
17
|
+
return pos;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
return null;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function buildHighlightDecorations(
|
|
25
|
+
doc: ProseMirrorNode,
|
|
26
|
+
highlights: CommentAnchor[],
|
|
27
|
+
): DecorationSet {
|
|
28
|
+
const decorations: Decoration[] = [];
|
|
29
|
+
|
|
30
|
+
for (const highlight of highlights) {
|
|
31
|
+
const from = textOffsetToPos(doc, highlight.position_from);
|
|
32
|
+
const to = textOffsetToPos(doc, highlight.position_to);
|
|
33
|
+
if (from != null && to != null && from < to) {
|
|
34
|
+
decorations.push(
|
|
35
|
+
Decoration.inline(from, to, {
|
|
36
|
+
class: "comment-anchor-highlight",
|
|
37
|
+
title: highlight.anchor_text,
|
|
38
|
+
}),
|
|
39
|
+
);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
return DecorationSet.create(doc, decorations);
|
|
44
|
+
}
|