diff --git a/api/migrations/202701010001_create_git_pull_sessions.sql b/api/migrations/202701010001_create_git_pull_sessions.sql new file mode 100644 index 00000000..5f4bffcb --- /dev/null +++ b/api/migrations/202701010001_create_git_pull_sessions.sql @@ -0,0 +1,13 @@ +CREATE TABLE IF NOT EXISTS git_pull_sessions ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + workspace_id UUID NOT NULL, + status TEXT NOT NULL DEFAULT 'pending', -- pending | resolving | merged | stale + conflicts JSONB NOT NULL DEFAULT '[]'::jsonb, + resolutions JSONB NOT NULL DEFAULT '[]'::jsonb, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + base_commit BYTEA NULL, + remote_commit BYTEA NULL +); + +CREATE INDEX IF NOT EXISTS idx_git_pull_sessions_workspace ON git_pull_sessions(workspace_id, updated_at DESC); diff --git a/api/migrations/202701010002_add_message_to_git_pull_sessions.sql b/api/migrations/202701010002_add_message_to_git_pull_sessions.sql new file mode 100644 index 00000000..d33fe17a --- /dev/null +++ b/api/migrations/202701010002_add_message_to_git_pull_sessions.sql @@ -0,0 +1,2 @@ +ALTER TABLE git_pull_sessions +ADD COLUMN IF NOT EXISTS message TEXT NULL; diff --git a/api/openapi/openapi.json b/api/openapi/openapi.json index 9b6a0f7f..a4ce10d3 100644 --- a/api/openapi/openapi.json +++ b/api/openapi/openapi.json @@ -1 +1 @@ -{"openapi":"3.0.3","info":{"title":"api","description":"","license":{"name":""},"version":"0.1.0"},"paths":{"/api/auth/login":{"post":{"tags":["Auth"],"operationId":"login","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/LoginRequest"}}},"required":true},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/LoginResponse"}}}}},"security":[{}]}},"/api/auth/logout":{"post":{"tags":["Auth"],"operationId":"logout","responses":{"204":{"description":""}}}},"/api/auth/me":{"get":{"tags":["Auth"],"operationId":"me","responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/UserResponse"}}}}}},"delete":{"tags":["Auth"],"operationId":"delete_account","responses":{"204":{"description":""}}}},"/api/auth/oauth/{provider}":{"post":{"tags":["Auth"],"operationId":"oauth_login","parameters":[{"name":"provider","in":"path","description":"OAuth provider identifier (e.g., google)","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/OAuthLoginRequest"}}},"required":true},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/LoginResponse"}}}}},"security":[{}]}},"/api/auth/oauth/{provider}/state":{"post":{"tags":["Auth"],"operationId":"oauth_state","parameters":[{"name":"provider","in":"path","description":"OAuth provider identifier","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/OAuthStateResponse"}}}}},"security":[{}]}},"/api/auth/providers":{"get":{"tags":["Auth"],"operationId":"list_oauth_providers","responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AuthProvidersResponse"}}}}},"security":[{}]}},"/api/auth/refresh":{"post":{"tags":["Auth"],"operationId":"refresh_session","responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/RefreshResponse"}}}}}}},"/api/auth/register":{"post":{"tags":["Auth"],"operationId":"register","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/RegisterRequest"}}},"required":true},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/UserResponse"}}}}},"security":[{}]}},"/api/auth/sessions":{"get":{"tags":["Auth"],"operationId":"list_sessions","responses":{"200":{"description":"","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/SessionResponse"}}}}}}}},"/api/auth/sessions/{id}":{"delete":{"tags":["Auth"],"operationId":"revoke_session","parameters":[{"name":"id","in":"path","description":"Session ID","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"204":{"description":""}}}},"/api/documents":{"get":{"tags":["Documents"],"operationId":"list_documents","parameters":[{"name":"query","in":"query","description":"Search query","required":false,"schema":{"type":"string","nullable":true}},{"name":"tag","in":"query","description":"Filter by tag","required":false,"schema":{"type":"string","nullable":true}},{"name":"state","in":"query","description":"Filter by document state (active|archived|all)","required":false,"schema":{"type":"string","nullable":true}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/DocumentListResponse"}}}}}},"post":{"tags":["Documents"],"operationId":"create_document","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateDocumentRequest"}}},"required":true},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Document"}}}}}}},"/api/documents/search":{"get":{"tags":["Documents"],"operationId":"search_documents","parameters":[{"name":"q","in":"query","description":"Query","required":false,"schema":{"type":"string","nullable":true}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/SearchResult"}}}}}}}},"/api/documents/{id}":{"get":{"tags":["Documents"],"operationId":"get_document","parameters":[{"name":"id","in":"path","description":"Document ID","required":true,"schema":{"type":"string","format":"uuid"}},{"name":"token","in":"query","description":"Share token (optional)","required":false,"schema":{"type":"string","nullable":true}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Document"}}}}}},"delete":{"tags":["Documents"],"operationId":"delete_document","parameters":[{"name":"id","in":"path","description":"Document ID","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"204":{"description":""}}},"patch":{"tags":["Documents"],"operationId":"update_document","parameters":[{"name":"id","in":"path","description":"Document ID","required":true,"schema":{"type":"string","format":"uuid"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateDocumentRequest"}}},"required":true},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Document"}}}}}}},"/api/documents/{id}/archive":{"post":{"tags":["Documents"],"operationId":"archive_document","parameters":[{"name":"id","in":"path","description":"Document ID","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Document"}}}},"404":{"description":"Document not found"},"409":{"description":"Document already archived"}}}},"/api/documents/{id}/backlinks":{"get":{"tags":["Documents"],"operationId":"getBacklinks","parameters":[{"name":"id","in":"path","description":"Document ID","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/BacklinksResponse"}}}}}}},"/api/documents/{id}/content":{"get":{"tags":["Documents"],"operationId":"get_document_content","parameters":[{"name":"id","in":"path","description":"Document ID","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":""}}},"put":{"tags":["Documents"],"operationId":"update_document_content","parameters":[{"name":"id","in":"path","description":"Document ID","required":true,"schema":{"type":"string","format":"uuid"}},{"name":"token","in":"query","description":"Share token (optional)","required":false,"schema":{"type":"string","nullable":true}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateDocumentContentRequest"}}},"required":true},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Document"}}}}}},"patch":{"tags":["Documents"],"operationId":"patch_document_content","parameters":[{"name":"id","in":"path","description":"Document ID","required":true,"schema":{"type":"string","format":"uuid"}},{"name":"token","in":"query","description":"Share token (optional)","required":false,"schema":{"type":"string","nullable":true}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/PatchDocumentContentRequest"}}},"required":true},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Document"}}}}}}},"/api/documents/{id}/download":{"get":{"tags":["Documents"],"operationId":"download_document","parameters":[{"name":"id","in":"path","description":"Document ID","required":true,"schema":{"type":"string","format":"uuid"}},{"name":"token","in":"query","description":"Share token (optional)","required":false,"schema":{"type":"string","nullable":true}},{"name":"format","in":"query","description":"Download format (see schema for supported values)","required":false,"schema":{"allOf":[{"$ref":"#/components/schemas/DownloadFormat"}],"nullable":true}}],"responses":{"200":{"description":"Document download","content":{"application/octet-stream":{"schema":{"$ref":"#/components/schemas/DocumentDownloadBinary"}}}},"401":{"description":"Unauthorized"},"404":{"description":"Document not found"}}}},"/api/documents/{id}/duplicate":{"post":{"tags":["Documents"],"operationId":"duplicate_document","parameters":[{"name":"id","in":"path","description":"Document ID","required":true,"schema":{"type":"string","format":"uuid"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/DuplicateDocumentRequest"}}},"required":true},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Document"}}}}}}},"/api/documents/{id}/links":{"get":{"tags":["Documents"],"operationId":"getOutgoingLinks","parameters":[{"name":"id","in":"path","description":"Document ID","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/OutgoingLinksResponse"}}}}}}},"/api/documents/{id}/snapshots":{"get":{"tags":["Documents"],"operationId":"list_document_snapshots","parameters":[{"name":"id","in":"path","description":"Document ID","required":true,"schema":{"type":"string","format":"uuid"}},{"name":"token","in":"query","description":"Share token (optional)","required":false,"schema":{"type":"string","nullable":true}},{"name":"limit","in":"query","description":"Maximum number of snapshots to return","required":false,"schema":{"type":"integer","format":"int64","nullable":true}},{"name":"offset","in":"query","description":"Offset for pagination","required":false,"schema":{"type":"integer","format":"int64","nullable":true}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SnapshotListResponse"}}}}}}},"/api/documents/{id}/snapshots/{snapshot_id}/diff":{"get":{"tags":["Documents"],"operationId":"get_document_snapshot_diff","parameters":[{"name":"id","in":"path","description":"Document ID","required":true,"schema":{"type":"string","format":"uuid"}},{"name":"snapshot_id","in":"path","description":"Snapshot ID","required":true,"schema":{"type":"string","format":"uuid"}},{"name":"token","in":"query","description":"Share token (optional)","required":false,"schema":{"type":"string","nullable":true}},{"name":"compare","in":"query","description":"Snapshot ID to compare against (defaults to current document state)","required":false,"schema":{"type":"string","format":"uuid","nullable":true}},{"name":"base","in":"query","description":"Base comparison to use when compare is not provided (auto|current|previous)","required":false,"schema":{"allOf":[{"$ref":"#/components/schemas/SnapshotDiffBaseParam"}],"nullable":true}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SnapshotDiffResponse"}}}}}}},"/api/documents/{id}/snapshots/{snapshot_id}/download":{"get":{"tags":["Documents"],"operationId":"download_document_snapshot","parameters":[{"name":"id","in":"path","description":"Document ID","required":true,"schema":{"type":"string","format":"uuid"}},{"name":"snapshot_id","in":"path","description":"Snapshot ID","required":true,"schema":{"type":"string","format":"uuid"}},{"name":"token","in":"query","description":"Share token (optional)","required":false,"schema":{"type":"string","nullable":true}}],"responses":{"200":{"description":"Snapshot archive","content":{"application/zip":{"schema":{"$ref":"#/components/schemas/DocumentArchiveBinary"}}}},"401":{"description":"Unauthorized"},"404":{"description":"Snapshot not found"}}}},"/api/documents/{id}/snapshots/{snapshot_id}/restore":{"post":{"tags":["Documents"],"operationId":"restore_document_snapshot","parameters":[{"name":"id","in":"path","description":"Document ID","required":true,"schema":{"type":"string","format":"uuid"}},{"name":"snapshot_id","in":"path","description":"Snapshot ID","required":true,"schema":{"type":"string","format":"uuid"}},{"name":"token","in":"query","description":"Share token (optional)","required":false,"schema":{"type":"string","nullable":true}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SnapshotRestoreResponse"}}}}}}},"/api/documents/{id}/unarchive":{"post":{"tags":["Documents"],"operationId":"unarchive_document","parameters":[{"name":"id","in":"path","description":"Document ID","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Document"}}}},"404":{"description":"Document not found"},"409":{"description":"Document is not archived"}}}},"/api/files":{"post":{"tags":["Files"],"summary":"POST /api/files (multipart/form-data)","description":"Fields:\n- file: binary file (required)\n- document_id: uuid (required by current schema)","operationId":"upload_file","requestBody":{"content":{"multipart/form-data":{"schema":{"$ref":"#/components/schemas/UploadFileMultipart"}}},"required":true},"responses":{"201":{"description":"File uploaded","content":{"application/json":{"schema":{"$ref":"#/components/schemas/UploadFileResponse"}}}}}}},"/api/files/documents/{filename}":{"get":{"tags":["Files"],"summary":"GET /api/files/documents/{filename}?document_id=uuid -> bytes","operationId":"get_file_by_name","parameters":[{"name":"filename","in":"path","description":"File name","required":true,"schema":{"type":"string"}},{"name":"document_id","in":"query","description":"Document ID","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"OK","content":{"application/octet-stream":{"schema":{"type":"string","format":"binary"}}}}}}},"/api/files/{id}":{"get":{"tags":["Files"],"summary":"GET /api/files/{id} -> bytes (fallback; primary is /uploads/{filename})","operationId":"get_file","parameters":[{"name":"id","in":"path","description":"File ID","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"OK","content":{"application/octet-stream":{"schema":{"type":"string","format":"binary"}}}}}}},"/api/git/changes":{"get":{"tags":["Git"],"operationId":"get_changes","responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/GitChangesResponse"}}}}}}},"/api/git/config":{"get":{"tags":["Git"],"operationId":"get_config","responses":{"200":{"description":"","content":{"application/json":{"schema":{"allOf":[{"$ref":"#/components/schemas/GitConfigResponse"}],"nullable":true}}}}}},"post":{"tags":["Git"],"operationId":"create_or_update_config","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateGitConfigRequest"}}},"required":true},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/GitConfigResponse"}}}}}},"delete":{"tags":["Git"],"operationId":"delete_config","responses":{"204":{"description":"Deleted"}}}},"/api/git/deinit":{"post":{"tags":["Git"],"operationId":"deinit_repository","responses":{"200":{"description":"OK"}}}},"/api/git/diff/commits/{from}/{to}":{"get":{"tags":["Git"],"operationId":"get_commit_diff","parameters":[{"name":"from","in":"path","description":"From","required":true,"schema":{"type":"string"}},{"name":"to","in":"path","description":"To","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/TextDiffResult"}}}}}}}},"/api/git/diff/working":{"get":{"tags":["Git"],"operationId":"get_working_diff","responses":{"200":{"description":"","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/TextDiffResult"}}}}}}}},"/api/git/gitignore/check":{"post":{"tags":["Git"],"operationId":"check_path_ignored","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CheckIgnoredRequest"}}},"required":true},"responses":{"200":{"description":"OK"}}}},"/api/git/gitignore/patterns":{"get":{"tags":["Git"],"operationId":"get_gitignore_patterns","responses":{"200":{"description":"OK"}}},"post":{"tags":["Git"],"operationId":"add_gitignore_patterns","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/AddPatternsRequest"}}},"required":true},"responses":{"200":{"description":"OK"}}}},"/api/git/history":{"get":{"tags":["Git"],"operationId":"get_history","responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/GitHistoryResponse"}}}}}}},"/api/git/ignore/doc/{id}":{"post":{"tags":["Git"],"operationId":"ignore_document","parameters":[{"name":"id","in":"path","description":"Document ID","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"OK"}}}},"/api/git/ignore/folder/{id}":{"post":{"tags":["Git"],"operationId":"ignore_folder","parameters":[{"name":"id","in":"path","description":"Folder ID","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"OK"}}}},"/api/git/init":{"post":{"tags":["Git"],"operationId":"init_repository","responses":{"200":{"description":"OK"}}}},"/api/git/status":{"get":{"tags":["Git"],"operationId":"get_status","responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/GitStatus"}}}}}}},"/api/git/sync":{"post":{"tags":["Git"],"operationId":"sync_now","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/GitSyncRequest"}}},"required":true},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/GitSyncResponse"}}}},"409":{"description":"Conflicts during rebase/pull"}}}},"/api/health":{"get":{"tags":["Health"],"operationId":"health","responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HealthResp"}}}}}}},"/api/markdown/render":{"post":{"tags":["Markdown"],"operationId":"render_markdown","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/RenderRequest"}}},"required":true},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/RenderResponseBody"}}}}}}},"/api/markdown/render-many":{"post":{"tags":["Markdown"],"operationId":"render_markdown_many","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/RenderManyRequest"}}},"required":true},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/RenderManyResponse"}}}}}}},"/api/me/api-tokens":{"get":{"tags":["Auth"],"operationId":"list_api_tokens","responses":{"200":{"description":"","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/ApiTokenItem"}}}}}}},"post":{"tags":["Auth"],"operationId":"create_api_token","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiTokenCreateRequest"}}},"required":true},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiTokenCreateResponse"}}}}}}},"/api/me/api-tokens/{id}":{"delete":{"tags":["Auth"],"operationId":"revoke_api_token","parameters":[{"name":"id","in":"path","description":"Token ID","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"204":{"description":""}}}},"/api/me/plugins/install-from-url":{"post":{"tags":["Plugins"],"operationId":"pluginsInstallFromUrl","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/InstallFromUrlBody"}}},"required":true},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/InstallResponse"}}}}}}},"/api/me/plugins/manifest":{"get":{"tags":["Plugins"],"operationId":"pluginsGetManifest","responses":{"200":{"description":"","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/ManifestItem"}}}}}}}},"/api/me/plugins/uninstall":{"post":{"tags":["Plugins"],"operationId":"pluginsUninstall","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UninstallBody"}}},"required":true},"responses":{"204":{"description":""}}}},"/api/me/plugins/updates":{"get":{"tags":["Plugins"],"operationId":"sse_updates","responses":{"200":{"description":"Plugin event stream"}}}},"/api/me/shortcuts":{"get":{"tags":["Auth"],"operationId":"get_user_shortcuts","responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/UserShortcutResponse"}}}}}},"put":{"tags":["Auth"],"operationId":"update_user_shortcuts","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateUserShortcutRequest"}}},"required":true},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/UserShortcutResponse"}}}}}}},"/api/plugins/{plugin}/docs/{doc_id}/kv/{key}":{"get":{"tags":["Plugins"],"operationId":"pluginsGetKv","parameters":[{"name":"plugin","in":"path","description":"Plugin ID","required":true,"schema":{"type":"string"}},{"name":"doc_id","in":"path","description":"Document ID","required":true,"schema":{"type":"string","format":"uuid"}},{"name":"key","in":"path","description":"Key","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/KvValueResponse"}}}}}},"put":{"tags":["Plugins"],"operationId":"pluginsPutKv","parameters":[{"name":"plugin","in":"path","description":"Plugin ID","required":true,"schema":{"type":"string"}},{"name":"doc_id","in":"path","description":"Document ID","required":true,"schema":{"type":"string","format":"uuid"}},{"name":"key","in":"path","description":"Key","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/KvValueBody"}}},"required":true},"responses":{"204":{"description":""}}}},"/api/plugins/{plugin}/docs/{doc_id}/records/{kind}":{"get":{"tags":["Plugins"],"operationId":"list_records","parameters":[{"name":"plugin","in":"path","description":"Plugin ID","required":true,"schema":{"type":"string"}},{"name":"doc_id","in":"path","description":"Document ID","required":true,"schema":{"type":"string","format":"uuid"}},{"name":"kind","in":"path","description":"Record kind","required":true,"schema":{"type":"string"}},{"name":"limit","in":"query","description":"Limit","required":false,"schema":{"type":"integer","format":"int64","nullable":true}},{"name":"offset","in":"query","description":"Offset","required":false,"schema":{"type":"integer","format":"int64","nullable":true}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/RecordsResponse"}}}}}},"post":{"tags":["Plugins"],"operationId":"pluginsCreateRecord","parameters":[{"name":"plugin","in":"path","description":"Plugin ID","required":true,"schema":{"type":"string"}},{"name":"doc_id","in":"path","description":"Document ID","required":true,"schema":{"type":"string","format":"uuid"}},{"name":"kind","in":"path","description":"Record kind","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateRecordBody"}}},"required":true},"responses":{"200":{"description":"","content":{"application/json":{"schema":{}}}}}}},"/api/plugins/{plugin}/exec/{action}":{"post":{"tags":["Plugins"],"operationId":"pluginsExecAction","parameters":[{"name":"plugin","in":"path","description":"Plugin ID","required":true,"schema":{"type":"string"}},{"name":"action","in":"path","description":"Action","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ExecBody"}}},"required":true},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ExecResultResponse"}}}}}}},"/api/plugins/{plugin}/records/{id}":{"delete":{"tags":["Plugins"],"operationId":"pluginsDeleteRecord","parameters":[{"name":"plugin","in":"path","description":"Plugin ID","required":true,"schema":{"type":"string"}},{"name":"id","in":"path","description":"Record ID","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"204":{"description":""}}},"patch":{"tags":["Plugins"],"operationId":"pluginsUpdateRecord","parameters":[{"name":"plugin","in":"path","description":"Plugin ID","required":true,"schema":{"type":"string"}},{"name":"id","in":"path","description":"Record ID","required":true,"schema":{"type":"string","format":"uuid"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateRecordBody"}}},"required":true},"responses":{"200":{"description":"","content":{"application/json":{"schema":{}}}}}}},"/api/public/documents/{id}":{"get":{"tags":["Public Documents"],"operationId":"get_publish_status","parameters":[{"name":"id","in":"path","description":"Document ID","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"Published status","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PublishResponse"}}}}}},"post":{"tags":["Public Documents"],"operationId":"publish_document","parameters":[{"name":"id","in":"path","description":"Document ID","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"Published","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PublishResponse"}}}}}},"delete":{"tags":["Public Documents"],"operationId":"unpublish_document","parameters":[{"name":"id","in":"path","description":"Document ID","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"204":{"description":"Unpublished"}}}},"/api/public/workspaces/{slug}":{"get":{"tags":["Public Documents"],"operationId":"list_workspace_public_documents","parameters":[{"name":"slug","in":"path","description":"Workspace slug","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"Public documents for workspace","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/PublicDocumentSummary"}}}}}}}},"/api/public/workspaces/{slug}/{id}":{"get":{"tags":["Public Documents"],"operationId":"get_public_by_workspace_and_id","parameters":[{"name":"slug","in":"path","description":"Workspace slug","required":true,"schema":{"type":"string"}},{"name":"id","in":"path","description":"Document ID","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"Document metadata","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Document"}}}}}}},"/api/public/workspaces/{slug}/{id}/content":{"get":{"tags":["Public Documents"],"operationId":"get_public_content_by_workspace_and_id","parameters":[{"name":"slug","in":"path","description":"Workspace slug","required":true,"schema":{"type":"string"}},{"name":"id","in":"path","description":"Document ID","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"Document content"}}}},"/api/shares":{"post":{"tags":["Sharing"],"operationId":"create_share","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateShareRequest"}}},"required":true},"responses":{"200":{"description":"Share link created","content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateShareResponse"}}}}}}},"/api/shares/active":{"get":{"tags":["Sharing"],"operationId":"list_active_shares","responses":{"200":{"description":"Active shares","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/ActiveShareItem"}}}}}}}},"/api/shares/applicable":{"get":{"tags":["Sharing"],"operationId":"list_applicable_shares","parameters":[{"name":"doc_id","in":"query","description":"Document ID","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"Shares that include the document","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/ApplicableShareItem"}}}}}}}},"/api/shares/browse":{"get":{"tags":["Sharing"],"operationId":"browse_share","parameters":[{"name":"token","in":"query","description":"Share token","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"Share tree","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ShareBrowseResponse"}}}}}}},"/api/shares/documents/{id}":{"get":{"tags":["Sharing"],"operationId":"list_document_shares","parameters":[{"name":"id","in":"path","description":"Document ID","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/ShareItem"}}}}}}}},"/api/shares/folders/{token}/materialize":{"post":{"tags":["Sharing"],"operationId":"materialize_folder_share","parameters":[{"name":"token","in":"path","description":"Folder share token","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"Created doc shares","content":{"application/json":{"schema":{"$ref":"#/components/schemas/MaterializeResponse"}}}}}}},"/api/shares/mounts":{"get":{"tags":["Sharing"],"operationId":"list_share_mounts","responses":{"200":{"description":"Share mounts","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/ShareMountItem"}}}}}}},"post":{"tags":["Sharing"],"operationId":"create_share_mount","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateShareMountRequest"}}},"required":true},"responses":{"200":{"description":"Saved share mount","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ShareMountItem"}}}}}}},"/api/shares/mounts/{id}":{"delete":{"tags":["Sharing"],"operationId":"delete_share_mount","parameters":[{"name":"id","in":"path","description":"Share mount ID","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"204":{"description":"Share mount removed"}}}},"/api/shares/validate":{"get":{"tags":["Sharing"],"operationId":"validate_share_token","parameters":[{"name":"token","in":"query","description":"Share token","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"Document info","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ShareDocumentResponse"}}}}}}},"/api/shares/{token}":{"delete":{"tags":["Sharing"],"operationId":"delete_share","parameters":[{"name":"token","in":"path","description":"Share token","required":true,"schema":{"type":"string"}}],"responses":{"204":{"description":"Share link deleted"}}}},"/api/tags":{"get":{"tags":["Tags"],"operationId":"list_tags","parameters":[{"name":"q","in":"query","description":"Filter contains","required":false,"schema":{"type":"string","nullable":true}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/TagItem"}}}}}}}},"/api/workspace-invitations/{token}/accept":{"post":{"tags":["Workspaces"],"operationId":"accept_invitation","parameters":[{"name":"token","in":"path","description":"Invitation token","required":true,"schema":{"type":"string"}}],"responses":{"204":{"description":""}}}},"/api/workspaces":{"get":{"tags":["Workspaces"],"operationId":"list_workspaces","responses":{"200":{"description":"","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/WorkspaceResponse"}}}}}}},"post":{"tags":["Workspaces"],"operationId":"create_workspace","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateWorkspaceRequest"}}},"required":true},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/WorkspaceResponse"}}}}}}},"/api/workspaces/{id}":{"get":{"tags":["Workspaces"],"operationId":"get_workspace_detail","parameters":[{"name":"id","in":"path","description":"Workspace ID","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/WorkspaceResponse"}}}}}},"put":{"tags":["Workspaces"],"operationId":"update_workspace","parameters":[{"name":"id","in":"path","description":"Workspace ID","required":true,"schema":{"type":"string","format":"uuid"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateWorkspaceRequest"}}},"required":true},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/WorkspaceResponse"}}}}}},"delete":{"tags":["Workspaces"],"operationId":"delete_workspace","parameters":[{"name":"id","in":"path","description":"Workspace ID","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"204":{"description":""}}}},"/api/workspaces/{id}/download":{"get":{"tags":["Workspaces"],"operationId":"download_workspace_archive","parameters":[{"name":"id","in":"path","description":"Workspace ID","required":true,"schema":{"type":"string","format":"uuid"}},{"name":"format","in":"query","description":"Download format (archive only)","required":false,"schema":{"allOf":[{"$ref":"#/components/schemas/DownloadFormat"}],"nullable":true}}],"responses":{"200":{"description":"Workspace download","content":{"application/octet-stream":{"schema":{"$ref":"#/components/schemas/DocumentDownloadBinary"}}}},"401":{"description":"Unauthorized"},"404":{"description":"Workspace not found"}}}},"/api/workspaces/{id}/invitations":{"get":{"tags":["Workspaces"],"operationId":"list_invitations","parameters":[{"name":"id","in":"path","description":"Workspace ID","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/WorkspaceInvitationResponse"}}}}}}},"post":{"tags":["Workspaces"],"operationId":"create_invitation","parameters":[{"name":"id","in":"path","description":"Workspace ID","required":true,"schema":{"type":"string","format":"uuid"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateWorkspaceInvitationRequest"}}},"required":true},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/WorkspaceInvitationResponse"}}}}}}},"/api/workspaces/{id}/invitations/{invitation_id}":{"delete":{"tags":["Workspaces"],"operationId":"revoke_invitation","parameters":[{"name":"id","in":"path","description":"Workspace ID","required":true,"schema":{"type":"string","format":"uuid"}},{"name":"invitation_id","in":"path","description":"Invitation ID","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/WorkspaceInvitationResponse"}}}}}}},"/api/workspaces/{id}/members":{"get":{"tags":["Workspaces"],"operationId":"list_members","parameters":[{"name":"id","in":"path","description":"Workspace ID","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/WorkspaceMemberResponse"}}}}}}}},"/api/workspaces/{id}/members/{user_id}":{"delete":{"tags":["Workspaces"],"operationId":"remove_member","parameters":[{"name":"id","in":"path","description":"Workspace ID","required":true,"schema":{"type":"string","format":"uuid"}},{"name":"user_id","in":"path","description":"Target user ID","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"204":{"description":""}}},"patch":{"tags":["Workspaces"],"operationId":"update_member_role","parameters":[{"name":"id","in":"path","description":"Workspace ID","required":true,"schema":{"type":"string","format":"uuid"}},{"name":"user_id","in":"path","description":"Target user ID","required":true,"schema":{"type":"string","format":"uuid"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateMemberRoleRequest"}}},"required":true},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/WorkspaceMemberResponse"}}}}}}},"/api/workspaces/{id}/permissions":{"get":{"tags":["Workspaces"],"operationId":"get_workspace_permissions","parameters":[{"name":"id","in":"path","description":"Workspace ID","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/WorkspacePermissionsResponse"}}}}}}},"/api/workspaces/{id}/roles":{"get":{"tags":["Workspaces"],"operationId":"list_roles","parameters":[{"name":"id","in":"path","description":"Workspace ID","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/WorkspaceRoleResponse"}}}}}}},"post":{"tags":["Workspaces"],"operationId":"create_role","parameters":[{"name":"id","in":"path","description":"Workspace ID","required":true,"schema":{"type":"string","format":"uuid"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateWorkspaceRoleRequest"}}},"required":true},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/WorkspaceRoleResponse"}}}}}}},"/api/workspaces/{id}/roles/{role_id}":{"delete":{"tags":["Workspaces"],"operationId":"delete_role","parameters":[{"name":"id","in":"path","description":"Workspace ID","required":true,"schema":{"type":"string","format":"uuid"}},{"name":"role_id","in":"path","description":"Role ID","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"204":{"description":""}}},"patch":{"tags":["Workspaces"],"operationId":"update_role","parameters":[{"name":"id","in":"path","description":"Workspace ID","required":true,"schema":{"type":"string","format":"uuid"}},{"name":"role_id","in":"path","description":"Role ID","required":true,"schema":{"type":"string","format":"uuid"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateWorkspaceRoleRequest"}}},"required":true},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/WorkspaceRoleResponse"}}}}}}},"/api/workspaces/{id}/switch":{"post":{"tags":["Workspaces"],"operationId":"switch_workspace","parameters":[{"name":"id","in":"path","description":"Workspace ID","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SwitchWorkspaceResponse"}}}}}}},"/api/yjs/{id}":{"get":{"tags":["Realtime"],"operationId":"axum_ws_entry","parameters":[{"name":"id","in":"path","description":"Document ID (UUID)","required":true,"schema":{"type":"string"}},{"name":"token","in":"query","description":"JWT or share token","required":false,"schema":{"type":"string","nullable":true}},{"name":"Authorization","in":"header","description":"Bearer token (JWT or share token)","required":false,"schema":{"type":"string","nullable":true}}],"responses":{"101":{"description":"Switching Protocols (WebSocket upgrade)"},"401":{"description":"Unauthorized"}}}}},"components":{"schemas":{"ActiveShareItem":{"type":"object","required":["id","token","permission","created_at","document_id","document_title","document_type","url"],"properties":{"created_at":{"type":"string","format":"date-time"},"document_id":{"type":"string","format":"uuid"},"document_title":{"type":"string"},"document_type":{"type":"string","description":"'document' or 'folder'"},"expires_at":{"type":"string","format":"date-time","nullable":true},"id":{"type":"string","format":"uuid"},"parent_share_id":{"type":"string","format":"uuid","nullable":true},"permission":{"type":"string"},"token":{"type":"string"},"url":{"type":"string"}}},"AddPatternsRequest":{"type":"object","required":["patterns"],"properties":{"patterns":{"type":"array","items":{"type":"string"}}}},"ApiTokenCreateRequest":{"type":"object","properties":{"name":{"type":"string","example":"Deploy token","nullable":true}}},"ApiTokenCreateResponse":{"type":"object","required":["id","name","created_at","token"],"properties":{"created_at":{"type":"string","format":"date-time"},"id":{"type":"string","format":"uuid"},"name":{"type":"string"},"token":{"type":"string"}}},"ApiTokenItem":{"type":"object","required":["id","name","created_at"],"properties":{"created_at":{"type":"string","format":"date-time"},"id":{"type":"string","format":"uuid"},"last_used_at":{"type":"string","format":"date-time","nullable":true},"name":{"type":"string"},"revoked_at":{"type":"string","format":"date-time","nullable":true}}},"ApplicableShareItem":{"type":"object","required":["token","permission","scope","excluded"],"properties":{"excluded":{"type":"boolean"},"permission":{"type":"string"},"scope":{"type":"string","description":"'document' or 'folder'"},"token":{"type":"string"}}},"AuthProviderInfoResponse":{"type":"object","required":["id","requires_state","client_ids"],"properties":{"authorization_url":{"type":"string","nullable":true},"client_ids":{"type":"array","items":{"type":"string"}},"id":{"type":"string"},"name":{"type":"string","nullable":true},"redirect_uri":{"type":"string","nullable":true},"requires_state":{"type":"boolean"},"scopes":{"type":"array","items":{"type":"string"}}}},"AuthProvidersResponse":{"type":"object","required":["providers"],"properties":{"providers":{"type":"array","items":{"$ref":"#/components/schemas/AuthProviderInfoResponse"}}}},"BacklinkInfo":{"type":"object","required":["document_id","title","document_type","link_type","link_count"],"properties":{"document_id":{"type":"string"},"document_type":{"type":"string"},"file_path":{"type":"string","nullable":true},"link_count":{"type":"integer","format":"int64"},"link_text":{"type":"string","nullable":true},"link_type":{"type":"string"},"title":{"type":"string"}}},"BacklinksResponse":{"type":"object","required":["backlinks","total_count"],"properties":{"backlinks":{"type":"array","items":{"$ref":"#/components/schemas/BacklinkInfo"}},"total_count":{"type":"integer","minimum":0}}},"CheckIgnoredRequest":{"type":"object","required":["path"],"properties":{"path":{"type":"string"}}},"CreateDocumentRequest":{"type":"object","properties":{"parent_id":{"type":"string","format":"uuid","nullable":true},"title":{"type":"string","nullable":true},"type":{"type":"string","nullable":true}}},"CreateGitConfigRequest":{"type":"object","required":["repository_url","auth_type","auth_data"],"properties":{"auth_data":{},"auth_type":{"type":"string"},"auto_sync":{"type":"boolean","nullable":true},"branch_name":{"type":"string","nullable":true},"repository_url":{"type":"string"}}},"CreateRecordBody":{"type":"object","required":["data"],"properties":{"data":{}}},"CreateShareMountRequest":{"type":"object","required":["token"],"properties":{"parent_folder_id":{"type":"string","format":"uuid","nullable":true},"token":{"type":"string"}}},"CreateShareRequest":{"type":"object","required":["document_id"],"properties":{"document_id":{"type":"string","format":"uuid"},"expires_at":{"type":"string","format":"date-time","nullable":true},"permission":{"type":"string","nullable":true}}},"CreateShareResponse":{"type":"object","required":["token","url"],"properties":{"token":{"type":"string"},"url":{"type":"string"}}},"CreateWorkspaceInvitationRequest":{"type":"object","required":["email","role_kind"],"properties":{"custom_role_id":{"type":"string","format":"uuid","nullable":true},"email":{"type":"string"},"expires_at":{"type":"string","format":"date-time","nullable":true},"role_kind":{"type":"string"},"system_role":{"type":"string","nullable":true}}},"CreateWorkspaceRequest":{"type":"object","required":["name"],"properties":{"description":{"type":"string","nullable":true},"icon":{"type":"string","nullable":true},"name":{"type":"string"}}},"CreateWorkspaceRoleRequest":{"type":"object","required":["name","base_role"],"properties":{"base_role":{"type":"string"},"description":{"type":"string","nullable":true},"name":{"type":"string"},"overrides":{"type":"array","items":{"$ref":"#/components/schemas/PermissionOverridePayload"},"nullable":true},"priority":{"type":"integer","format":"int32","nullable":true}}},"Document":{"type":"object","required":["id","owner_id","workspace_id","title","type","created_at","updated_at","slug","desired_path"],"properties":{"archived_at":{"type":"string","format":"date-time","nullable":true},"archived_by":{"type":"string","format":"uuid","nullable":true},"archived_parent_id":{"type":"string","format":"uuid","nullable":true},"created_at":{"type":"string","format":"date-time"},"created_by":{"type":"string","format":"uuid","nullable":true},"created_by_plugin":{"type":"string","nullable":true},"desired_path":{"type":"string"},"id":{"type":"string","format":"uuid"},"owner_id":{"type":"string","format":"uuid"},"parent_id":{"type":"string","format":"uuid","nullable":true},"path":{"type":"string","nullable":true},"slug":{"type":"string"},"title":{"type":"string"},"type":{"type":"string"},"updated_at":{"type":"string","format":"date-time"},"workspace_id":{"type":"string","format":"uuid"}}},"DocumentArchiveBinary":{"type":"string","format":"binary"},"DocumentDownloadBinary":{"type":"string","format":"binary"},"DocumentListResponse":{"type":"object","required":["items"],"properties":{"items":{"type":"array","items":{"$ref":"#/components/schemas/Document"}}}},"DocumentPatchOperationRequest":{"oneOf":[{"type":"object","required":["offset","text","op"],"properties":{"offset":{"type":"integer","minimum":0},"op":{"type":"string","enum":["insert"]},"text":{"type":"string"}}},{"type":"object","required":["offset","length","op"],"properties":{"length":{"type":"integer","minimum":0},"offset":{"type":"integer","minimum":0},"op":{"type":"string","enum":["delete"]}}},{"type":"object","required":["offset","length","text","op"],"properties":{"length":{"type":"integer","minimum":0},"offset":{"type":"integer","minimum":0},"op":{"type":"string","enum":["replace"]},"text":{"type":"string"}}}],"discriminator":{"propertyName":"op"}},"DownloadDocumentQuery":{"type":"object","properties":{"format":{"$ref":"#/components/schemas/DownloadFormat"},"token":{"type":"string","nullable":true}}},"DownloadFormat":{"type":"string","enum":["archive","markdown","html","html5","pdf","docx","latex","beamer","context","man","mediawiki","dokuwiki","textile","org","texinfo","opml","docbook","opendocument","odt","rtf","epub","epub3","fb2","asciidoc","icml","slidy","slideous","dzslides","revealjs","s5","json","plain","commonmark","commonmark_x","markdown_strict","markdown_phpextra","markdown_github","rst","native","haddock"]},"DownloadWorkspaceQuery":{"type":"object","properties":{"format":{"$ref":"#/components/schemas/DownloadFormat"}}},"DuplicateDocumentRequest":{"type":"object","properties":{"parent_id":{"type":"string","nullable":true},"title":{"type":"string","nullable":true}}},"ExecBody":{"type":"object","properties":{"payload":{"nullable":true}}},"ExecResultResponse":{"type":"object","required":["ok","effects"],"properties":{"data":{"nullable":true},"effects":{"type":"array","items":{}},"error":{"nullable":true},"ok":{"type":"boolean"}}},"GitChangeItem":{"type":"object","required":["path","status"],"properties":{"path":{"type":"string"},"status":{"type":"string"}}},"GitChangesResponse":{"type":"object","required":["files"],"properties":{"files":{"type":"array","items":{"$ref":"#/components/schemas/GitChangeItem"}}}},"GitCommitItem":{"type":"object","required":["hash","message","author_name","author_email","time"],"properties":{"author_email":{"type":"string"},"author_name":{"type":"string"},"hash":{"type":"string"},"message":{"type":"string"},"time":{"type":"string","format":"date-time"}}},"GitConfigResponse":{"type":"object","required":["id","repository_url","branch_name","auth_type","auto_sync","created_at","updated_at"],"properties":{"auth_type":{"type":"string"},"auto_sync":{"type":"boolean"},"branch_name":{"type":"string"},"created_at":{"type":"string","format":"date-time"},"id":{"type":"string","format":"uuid"},"remote_check":{"allOf":[{"$ref":"#/components/schemas/GitRemoteCheckResponse"}],"nullable":true},"repository_url":{"type":"string"},"updated_at":{"type":"string","format":"date-time"}}},"GitHistoryResponse":{"type":"object","required":["commits"],"properties":{"commits":{"type":"array","items":{"$ref":"#/components/schemas/GitCommitItem"}}}},"GitRemoteCheckResponse":{"type":"object","required":["ok","message"],"properties":{"message":{"type":"string"},"ok":{"type":"boolean"},"reason":{"type":"string","nullable":true}}},"GitStatus":{"type":"object","required":["repository_initialized","has_remote","uncommitted_changes","untracked_files","sync_enabled"],"properties":{"current_branch":{"type":"string","nullable":true},"has_remote":{"type":"boolean"},"last_sync":{"type":"string","format":"date-time","nullable":true},"last_sync_commit_hash":{"type":"string","nullable":true},"last_sync_message":{"type":"string","nullable":true},"last_sync_status":{"type":"string","nullable":true},"repository_initialized":{"type":"boolean"},"sync_enabled":{"type":"boolean"},"uncommitted_changes":{"type":"integer","format":"int32","minimum":0},"untracked_files":{"type":"integer","format":"int32","minimum":0}}},"GitSyncRequest":{"type":"object","properties":{"force":{"type":"boolean","nullable":true},"full_scan":{"type":"boolean","nullable":true},"message":{"type":"string","nullable":true},"skip_push":{"type":"boolean","nullable":true}}},"GitSyncResponse":{"type":"object","required":["success","message","files_changed"],"properties":{"commit_hash":{"type":"string","nullable":true},"files_changed":{"type":"integer","format":"int32","minimum":0},"message":{"type":"string"},"success":{"type":"boolean"}}},"HealthResp":{"type":"object","required":["status"],"properties":{"status":{"type":"string"}}},"InstallFromUrlBody":{"type":"object","required":["url"],"properties":{"token":{"type":"string","nullable":true},"url":{"type":"string"}}},"InstallResponse":{"type":"object","required":["id","version"],"properties":{"id":{"type":"string"},"version":{"type":"string"}}},"KvValueBody":{"type":"object","required":["value"],"properties":{"value":{}}},"KvValueResponse":{"type":"object","required":["value"],"properties":{"value":{}}},"LoginRequest":{"type":"object","required":["email","password"],"properties":{"email":{"type":"string"},"password":{"type":"string"},"remember_me":{"type":"boolean"}}},"LoginResponse":{"type":"object","required":["access_token","user"],"properties":{"access_token":{"type":"string"},"user":{"$ref":"#/components/schemas/UserResponse"}}},"ManifestItem":{"type":"object","required":["id","version","scope","mounts","frontend","permissions","config","ui"],"properties":{"author":{"type":"string","nullable":true},"config":{},"frontend":{},"id":{"type":"string"},"mounts":{"type":"array","items":{"type":"string"}},"name":{"type":"string","nullable":true},"permissions":{"type":"array","items":{"type":"string"}},"repository":{"type":"string","nullable":true},"scope":{"type":"string"},"ui":{},"version":{"type":"string"}}},"MaterializeResponse":{"type":"object","required":["created"],"properties":{"created":{"type":"integer","format":"int64"}}},"OAuthLoginRequest":{"type":"object","properties":{"code":{"type":"string","nullable":true},"credential":{"type":"string","nullable":true},"redirect_uri":{"type":"string","nullable":true},"remember_me":{"type":"boolean"},"state":{"type":"string","nullable":true}}},"OAuthStateResponse":{"type":"object","required":["state"],"properties":{"state":{"type":"string"}}},"OutgoingLink":{"type":"object","required":["document_id","title","document_type","link_type"],"properties":{"document_id":{"type":"string"},"document_type":{"type":"string"},"file_path":{"type":"string","nullable":true},"link_text":{"type":"string","nullable":true},"link_type":{"type":"string"},"position_end":{"type":"integer","format":"int32","nullable":true},"position_start":{"type":"integer","format":"int32","nullable":true},"title":{"type":"string"}}},"OutgoingLinksResponse":{"type":"object","required":["links","total_count"],"properties":{"links":{"type":"array","items":{"$ref":"#/components/schemas/OutgoingLink"}},"total_count":{"type":"integer","minimum":0}}},"PatchDocumentContentRequest":{"type":"object","required":["operations"],"properties":{"operations":{"type":"array","items":{"$ref":"#/components/schemas/DocumentPatchOperationRequest"}}}},"PermissionOverridePayload":{"type":"object","required":["permission","allowed"],"properties":{"allowed":{"type":"boolean"},"permission":{"type":"string"}}},"PlaceholderItemPayload":{"type":"object","required":["kind","id","code"],"properties":{"code":{"type":"string"},"id":{"type":"string"},"kind":{"type":"string"}}},"PublicDocumentSummary":{"type":"object","required":["id","title","updated_at","published_at"],"properties":{"id":{"type":"string","format":"uuid"},"published_at":{"type":"string","format":"date-time"},"title":{"type":"string"},"updated_at":{"type":"string","format":"date-time"}}},"PublishResponse":{"type":"object","required":["slug","public_url"],"properties":{"public_url":{"type":"string"},"slug":{"type":"string"}}},"RecordsResponse":{"type":"object","required":["items"],"properties":{"items":{"type":"array","items":{}}}},"RefreshResponse":{"type":"object","required":["access_token"],"properties":{"access_token":{"type":"string"}}},"RegisterRequest":{"type":"object","required":["email","name","password"],"properties":{"email":{"type":"string"},"name":{"type":"string"},"password":{"type":"string"}}},"RenderManyRequest":{"type":"object","required":["items"],"properties":{"items":{"type":"array","items":{"$ref":"#/components/schemas/RenderRequest"}}}},"RenderManyResponse":{"type":"object","required":["items"],"properties":{"items":{"type":"array","items":{"$ref":"#/components/schemas/RenderResponseBody"}}}},"RenderOptionsPayload":{"type":"object","properties":{"absolute_attachments":{"type":"boolean","default":null,"nullable":true},"base_origin":{"type":"string","default":null,"nullable":true},"doc_id":{"type":"string","format":"uuid","default":null,"nullable":true},"features":{"type":"array","items":{"type":"string"},"default":null,"nullable":true},"flavor":{"type":"string","default":null,"nullable":true},"hardbreaks":{"type":"boolean","default":null,"nullable":true},"sanitize":{"type":"boolean","default":null,"nullable":true},"theme":{"type":"string","default":null,"nullable":true},"token":{"type":"string","default":null,"nullable":true}}},"RenderRequest":{"type":"object","required":["text"],"properties":{"options":{"$ref":"#/components/schemas/RenderOptionsPayload"},"text":{"type":"string"}}},"RenderResponseBody":{"type":"object","required":["html","hash"],"properties":{"hash":{"type":"string"},"html":{"type":"string"},"placeholders":{"type":"array","items":{"$ref":"#/components/schemas/PlaceholderItemPayload"}}}},"SearchResult":{"type":"object","required":["id","title","document_type","updated_at"],"properties":{"document_type":{"type":"string"},"id":{"type":"string","format":"uuid"},"path":{"type":"string","nullable":true},"title":{"type":"string"},"updated_at":{"type":"string","format":"date-time"}}},"SessionResponse":{"type":"object","required":["id","workspace_id","remember_me","created_at","last_seen_at","expires_at","current"],"properties":{"created_at":{"type":"string","format":"date-time"},"current":{"type":"boolean"},"expires_at":{"type":"string","format":"date-time"},"id":{"type":"string","format":"uuid"},"ip_address":{"type":"string","nullable":true},"last_seen_at":{"type":"string","format":"date-time"},"remember_me":{"type":"boolean"},"user_agent":{"type":"string","nullable":true},"workspace_id":{"type":"string","format":"uuid"}}},"ShareBrowseResponse":{"type":"object","required":["tree"],"properties":{"tree":{"type":"array","items":{"$ref":"#/components/schemas/ShareBrowseTreeItem"}}}},"ShareBrowseTreeItem":{"type":"object","required":["id","title","type","created_at","updated_at"],"properties":{"created_at":{"type":"string","format":"date-time"},"id":{"type":"string","format":"uuid"},"parent_id":{"type":"string","format":"uuid","nullable":true},"title":{"type":"string"},"type":{"type":"string","example":"document"},"updated_at":{"type":"string","format":"date-time"}}},"ShareDocumentResponse":{"type":"object","required":["id","title","permission"],"properties":{"content":{"type":"string","nullable":true},"id":{"type":"string","format":"uuid"},"permission":{"type":"string"},"title":{"type":"string"}}},"ShareItem":{"type":"object","required":["id","token","permission","url","scope"],"properties":{"expires_at":{"type":"string","format":"date-time","nullable":true},"id":{"type":"string","format":"uuid"},"parent_share_id":{"type":"string","format":"uuid","description":"If present, this document share was materialized from a folder share","nullable":true},"permission":{"type":"string"},"scope":{"type":"string","description":"document | folder"},"token":{"type":"string"},"url":{"type":"string"}}},"ShareMountItem":{"type":"object","required":["id","token","target_document_id","target_document_type","target_title","permission","created_at"],"properties":{"created_at":{"type":"string","format":"date-time"},"id":{"type":"string","format":"uuid"},"parent_folder_id":{"type":"string","format":"uuid","nullable":true},"permission":{"type":"string"},"target_document_id":{"type":"string","format":"uuid"},"target_document_type":{"type":"string"},"target_title":{"type":"string"},"token":{"type":"string"}}},"SnapshotDiffBaseParam":{"type":"string","enum":["auto","current","previous"]},"SnapshotDiffKind":{"type":"string","enum":["current","snapshot"]},"SnapshotDiffResponse":{"type":"object","required":["base","target","diff"],"properties":{"base":{"$ref":"#/components/schemas/SnapshotDiffSideResponse"},"diff":{"$ref":"#/components/schemas/TextDiffResult"},"target":{"$ref":"#/components/schemas/SnapshotDiffSideResponse"}}},"SnapshotDiffSideResponse":{"type":"object","required":["kind","markdown"],"properties":{"kind":{"$ref":"#/components/schemas/SnapshotDiffKind"},"markdown":{"type":"string"},"snapshot":{"allOf":[{"$ref":"#/components/schemas/SnapshotSummary"}],"nullable":true}}},"SnapshotListResponse":{"type":"object","required":["items"],"properties":{"items":{"type":"array","items":{"$ref":"#/components/schemas/SnapshotSummary"}}}},"SnapshotRestoreResponse":{"type":"object","required":["snapshot"],"properties":{"snapshot":{"$ref":"#/components/schemas/SnapshotSummary"}}},"SnapshotSummary":{"type":"object","required":["id","document_id","label","kind","created_at","byte_size","content_hash"],"properties":{"byte_size":{"type":"integer","format":"int64"},"content_hash":{"type":"string"},"created_at":{"type":"string","format":"date-time"},"created_by":{"type":"string","format":"uuid","nullable":true},"document_id":{"type":"string","format":"uuid"},"id":{"type":"string","format":"uuid"},"kind":{"type":"string"},"label":{"type":"string"},"notes":{"type":"string","nullable":true}}},"SwitchWorkspaceResponse":{"type":"object","required":["access_token"],"properties":{"access_token":{"type":"string"}}},"TagItem":{"type":"object","required":["name","count"],"properties":{"count":{"type":"integer","format":"int64"},"name":{"type":"string"}}},"TextDiffLine":{"type":"object","required":["line_type","content"],"properties":{"content":{"type":"string"},"line_type":{"$ref":"#/components/schemas/TextDiffLineType"},"new_line_number":{"type":"integer","format":"int32","nullable":true,"minimum":0},"old_line_number":{"type":"integer","format":"int32","nullable":true,"minimum":0}}},"TextDiffLineType":{"type":"string","enum":["added","deleted","context"]},"TextDiffResult":{"type":"object","required":["file_path","diff_lines"],"properties":{"diff_lines":{"type":"array","items":{"$ref":"#/components/schemas/TextDiffLine"}},"file_path":{"type":"string"},"new_content":{"type":"string","nullable":true},"old_content":{"type":"string","nullable":true}}},"UninstallBody":{"type":"object","required":["id"],"properties":{"id":{"type":"string"}}},"UpdateDocumentContentRequest":{"type":"object","required":["content"],"properties":{"content":{"type":"string"}}},"UpdateDocumentRequest":{"type":"object","properties":{"parent_id":{"type":"string","nullable":true},"title":{"type":"string","nullable":true}}},"UpdateGitConfigRequest":{"type":"object","properties":{"auth_data":{"nullable":true},"auth_type":{"type":"string","nullable":true},"auto_sync":{"type":"boolean","nullable":true},"branch_name":{"type":"string","nullable":true},"repository_url":{"type":"string","nullable":true}}},"UpdateMemberRoleRequest":{"type":"object","required":["role_kind"],"properties":{"custom_role_id":{"type":"string","format":"uuid","nullable":true},"role_kind":{"type":"string"},"system_role":{"type":"string","nullable":true}}},"UpdateRecordBody":{"type":"object","required":["patch"],"properties":{"patch":{}}},"UpdateUserShortcutRequest":{"type":"object","properties":{"bindings":{"type":"object"},"leader_key":{"type":"string","example":"","nullable":true}}},"UpdateWorkspaceRequest":{"type":"object","properties":{"description":{"type":"string","nullable":true},"icon":{"type":"string","nullable":true},"name":{"type":"string","nullable":true}}},"UpdateWorkspaceRoleRequest":{"type":"object","properties":{"base_role":{"type":"string","nullable":true},"description":{"type":"string","nullable":true},"name":{"type":"string","nullable":true},"overrides":{"type":"array","items":{"$ref":"#/components/schemas/PermissionOverridePayload"},"nullable":true},"priority":{"type":"integer","format":"int32","nullable":true}}},"UploadFileMultipart":{"type":"object","required":["file","document_id"],"properties":{"document_id":{"type":"string","format":"uuid","description":"Target document ID"},"file":{"type":"string","format":"binary","description":"File to upload"}}},"UploadFileResponse":{"type":"object","required":["id","url","filename","size"],"properties":{"content_type":{"type":"string","nullable":true},"filename":{"type":"string"},"id":{"type":"string","format":"uuid"},"size":{"type":"integer","format":"int64"},"url":{"type":"string"}}},"UserResponse":{"type":"object","required":["id","email","name","workspaces"],"properties":{"active_workspace":{"allOf":[{"$ref":"#/components/schemas/WorkspaceMembershipResponse"}],"nullable":true},"active_workspace_id":{"type":"string","format":"uuid","nullable":true},"active_workspace_permissions":{"type":"array","items":{"type":"string"}},"email":{"type":"string"},"id":{"type":"string","format":"uuid"},"name":{"type":"string"},"workspaces":{"type":"array","items":{"$ref":"#/components/schemas/WorkspaceMembershipResponse"}}}},"UserShortcutResponse":{"type":"object","required":["bindings"],"properties":{"bindings":{"type":"object"},"leader_key":{"type":"string","example":"","nullable":true},"updated_at":{"type":"string","format":"date-time","nullable":true}}},"WorkspaceInvitationResponse":{"type":"object","required":["id","workspace_id","email","role_kind","invited_by","token","created_at"],"properties":{"accepted_at":{"type":"string","format":"date-time","nullable":true},"accepted_by":{"type":"string","format":"uuid","nullable":true},"created_at":{"type":"string","format":"date-time"},"custom_role_id":{"type":"string","format":"uuid","nullable":true},"email":{"type":"string"},"expires_at":{"type":"string","format":"date-time","nullable":true},"id":{"type":"string","format":"uuid"},"invited_by":{"type":"string","format":"uuid"},"revoked_at":{"type":"string","format":"date-time","nullable":true},"role_kind":{"type":"string"},"system_role":{"type":"string","nullable":true},"token":{"type":"string"},"workspace_id":{"type":"string","format":"uuid"}}},"WorkspaceMemberResponse":{"type":"object","required":["workspace_id","user_id","email","name","role_kind","is_default"],"properties":{"custom_role_id":{"type":"string","format":"uuid","nullable":true},"email":{"type":"string"},"is_default":{"type":"boolean"},"name":{"type":"string"},"role_kind":{"type":"string"},"system_role":{"type":"string","nullable":true},"user_id":{"type":"string","format":"uuid"},"workspace_id":{"type":"string","format":"uuid"}}},"WorkspaceMembershipResponse":{"type":"object","required":["id","name","slug","is_personal","role_kind","is_default"],"properties":{"custom_role_id":{"type":"string","format":"uuid","nullable":true},"description":{"type":"string","nullable":true},"icon":{"type":"string","nullable":true},"id":{"type":"string","format":"uuid"},"is_default":{"type":"boolean"},"is_personal":{"type":"boolean"},"name":{"type":"string"},"role_kind":{"type":"string"},"slug":{"type":"string"},"system_role":{"type":"string","nullable":true}}},"WorkspacePermissionsResponse":{"type":"object","required":["workspace_id","permissions"],"properties":{"permissions":{"type":"array","items":{"type":"string"}},"workspace_id":{"type":"string","format":"uuid"}}},"WorkspaceResponse":{"type":"object","required":["id","name","slug","is_personal","role_kind","is_default"],"properties":{"custom_role_id":{"type":"string","format":"uuid","nullable":true},"description":{"type":"string","nullable":true},"icon":{"type":"string","nullable":true},"id":{"type":"string","format":"uuid"},"is_default":{"type":"boolean"},"is_personal":{"type":"boolean"},"name":{"type":"string"},"role_kind":{"type":"string"},"slug":{"type":"string"},"system_role":{"type":"string","nullable":true}}},"WorkspaceRoleResponse":{"type":"object","required":["id","workspace_id","name","base_role","priority","overrides"],"properties":{"base_role":{"type":"string"},"description":{"type":"string","nullable":true},"id":{"type":"string","format":"uuid"},"name":{"type":"string"},"overrides":{"type":"array","items":{"$ref":"#/components/schemas/PermissionOverridePayload"}},"priority":{"type":"integer","format":"int32"},"workspace_id":{"type":"string","format":"uuid"}}}}},"tags":[{"name":"Auth","description":"Authentication"},{"name":"Documents","description":"Documents management"},{"name":"Files","description":"File management"},{"name":"Sharing","description":"Document sharing"},{"name":"Public Documents","description":"Public pages"},{"name":"Realtime","description":"Yjs WebSocket endpoint (/yjs/:id)"},{"name":"Git","description":"Git integration"},{"name":"Markdown","description":"Markdown rendering"},{"name":"Plugins","description":"Plugins management & data APIs"},{"name":"Health","description":"System health checks"}]} +{"openapi":"3.0.3","info":{"title":"api","description":"","license":{"name":""},"version":"0.1.0"},"paths":{"/api/auth/login":{"post":{"tags":["Auth"],"operationId":"login","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/LoginRequest"}}},"required":true},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/LoginResponse"}}}}},"security":[{}]}},"/api/auth/logout":{"post":{"tags":["Auth"],"operationId":"logout","responses":{"204":{"description":""}}}},"/api/auth/me":{"get":{"tags":["Auth"],"operationId":"me","responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/UserResponse"}}}}}},"delete":{"tags":["Auth"],"operationId":"delete_account","responses":{"204":{"description":""}}}},"/api/auth/oauth/{provider}":{"post":{"tags":["Auth"],"operationId":"oauth_login","parameters":[{"name":"provider","in":"path","description":"OAuth provider identifier (e.g., google)","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/OAuthLoginRequest"}}},"required":true},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/LoginResponse"}}}}},"security":[{}]}},"/api/auth/oauth/{provider}/state":{"post":{"tags":["Auth"],"operationId":"oauth_state","parameters":[{"name":"provider","in":"path","description":"OAuth provider identifier","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/OAuthStateResponse"}}}}},"security":[{}]}},"/api/auth/providers":{"get":{"tags":["Auth"],"operationId":"list_oauth_providers","responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AuthProvidersResponse"}}}}},"security":[{}]}},"/api/auth/refresh":{"post":{"tags":["Auth"],"operationId":"refresh_session","responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/RefreshResponse"}}}}}}},"/api/auth/register":{"post":{"tags":["Auth"],"operationId":"register","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/RegisterRequest"}}},"required":true},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/UserResponse"}}}}},"security":[{}]}},"/api/auth/sessions":{"get":{"tags":["Auth"],"operationId":"list_sessions","responses":{"200":{"description":"","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/SessionResponse"}}}}}}}},"/api/auth/sessions/{id}":{"delete":{"tags":["Auth"],"operationId":"revoke_session","parameters":[{"name":"id","in":"path","description":"Session ID","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"204":{"description":""}}}},"/api/documents":{"get":{"tags":["Documents"],"operationId":"list_documents","parameters":[{"name":"query","in":"query","description":"Search query","required":false,"schema":{"type":"string","nullable":true}},{"name":"tag","in":"query","description":"Filter by tag","required":false,"schema":{"type":"string","nullable":true}},{"name":"state","in":"query","description":"Filter by document state (active|archived|all)","required":false,"schema":{"type":"string","nullable":true}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/DocumentListResponse"}}}}}},"post":{"tags":["Documents"],"operationId":"create_document","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateDocumentRequest"}}},"required":true},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Document"}}}}}}},"/api/documents/search":{"get":{"tags":["Documents"],"operationId":"search_documents","parameters":[{"name":"q","in":"query","description":"Query","required":false,"schema":{"type":"string","nullable":true}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/SearchResult"}}}}}}}},"/api/documents/{id}":{"get":{"tags":["Documents"],"operationId":"get_document","parameters":[{"name":"id","in":"path","description":"Document ID","required":true,"schema":{"type":"string","format":"uuid"}},{"name":"token","in":"query","description":"Share token (optional)","required":false,"schema":{"type":"string","nullable":true}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Document"}}}}}},"delete":{"tags":["Documents"],"operationId":"delete_document","parameters":[{"name":"id","in":"path","description":"Document ID","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"204":{"description":""}}},"patch":{"tags":["Documents"],"operationId":"update_document","parameters":[{"name":"id","in":"path","description":"Document ID","required":true,"schema":{"type":"string","format":"uuid"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateDocumentRequest"}}},"required":true},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Document"}}}}}}},"/api/documents/{id}/archive":{"post":{"tags":["Documents"],"operationId":"archive_document","parameters":[{"name":"id","in":"path","description":"Document ID","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Document"}}}},"404":{"description":"Document not found"},"409":{"description":"Document already archived"}}}},"/api/documents/{id}/backlinks":{"get":{"tags":["Documents"],"operationId":"getBacklinks","parameters":[{"name":"id","in":"path","description":"Document ID","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/BacklinksResponse"}}}}}}},"/api/documents/{id}/content":{"get":{"tags":["Documents"],"operationId":"get_document_content","parameters":[{"name":"id","in":"path","description":"Document ID","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":""}}},"put":{"tags":["Documents"],"operationId":"update_document_content","parameters":[{"name":"id","in":"path","description":"Document ID","required":true,"schema":{"type":"string","format":"uuid"}},{"name":"token","in":"query","description":"Share token (optional)","required":false,"schema":{"type":"string","nullable":true}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateDocumentContentRequest"}}},"required":true},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Document"}}}}}},"patch":{"tags":["Documents"],"operationId":"patch_document_content","parameters":[{"name":"id","in":"path","description":"Document ID","required":true,"schema":{"type":"string","format":"uuid"}},{"name":"token","in":"query","description":"Share token (optional)","required":false,"schema":{"type":"string","nullable":true}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/PatchDocumentContentRequest"}}},"required":true},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Document"}}}}}}},"/api/documents/{id}/download":{"get":{"tags":["Documents"],"operationId":"download_document","parameters":[{"name":"id","in":"path","description":"Document ID","required":true,"schema":{"type":"string","format":"uuid"}},{"name":"token","in":"query","description":"Share token (optional)","required":false,"schema":{"type":"string","nullable":true}},{"name":"format","in":"query","description":"Download format (see schema for supported values)","required":false,"schema":{"allOf":[{"$ref":"#/components/schemas/DownloadFormat"}],"nullable":true}}],"responses":{"200":{"description":"Document download","content":{"application/octet-stream":{"schema":{"$ref":"#/components/schemas/DocumentDownloadBinary"}}}},"401":{"description":"Unauthorized"},"404":{"description":"Document not found"}}}},"/api/documents/{id}/duplicate":{"post":{"tags":["Documents"],"operationId":"duplicate_document","parameters":[{"name":"id","in":"path","description":"Document ID","required":true,"schema":{"type":"string","format":"uuid"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/DuplicateDocumentRequest"}}},"required":true},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Document"}}}}}}},"/api/documents/{id}/links":{"get":{"tags":["Documents"],"operationId":"getOutgoingLinks","parameters":[{"name":"id","in":"path","description":"Document ID","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/OutgoingLinksResponse"}}}}}}},"/api/documents/{id}/snapshots":{"get":{"tags":["Documents"],"operationId":"list_document_snapshots","parameters":[{"name":"id","in":"path","description":"Document ID","required":true,"schema":{"type":"string","format":"uuid"}},{"name":"token","in":"query","description":"Share token (optional)","required":false,"schema":{"type":"string","nullable":true}},{"name":"limit","in":"query","description":"Maximum number of snapshots to return","required":false,"schema":{"type":"integer","format":"int64","nullable":true}},{"name":"offset","in":"query","description":"Offset for pagination","required":false,"schema":{"type":"integer","format":"int64","nullable":true}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SnapshotListResponse"}}}}}}},"/api/documents/{id}/snapshots/{snapshot_id}/diff":{"get":{"tags":["Documents"],"operationId":"get_document_snapshot_diff","parameters":[{"name":"id","in":"path","description":"Document ID","required":true,"schema":{"type":"string","format":"uuid"}},{"name":"snapshot_id","in":"path","description":"Snapshot ID","required":true,"schema":{"type":"string","format":"uuid"}},{"name":"token","in":"query","description":"Share token (optional)","required":false,"schema":{"type":"string","nullable":true}},{"name":"compare","in":"query","description":"Snapshot ID to compare against (defaults to current document state)","required":false,"schema":{"type":"string","format":"uuid","nullable":true}},{"name":"base","in":"query","description":"Base comparison to use when compare is not provided (auto|current|previous)","required":false,"schema":{"allOf":[{"$ref":"#/components/schemas/SnapshotDiffBaseParam"}],"nullable":true}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SnapshotDiffResponse"}}}}}}},"/api/documents/{id}/snapshots/{snapshot_id}/download":{"get":{"tags":["Documents"],"operationId":"download_document_snapshot","parameters":[{"name":"id","in":"path","description":"Document ID","required":true,"schema":{"type":"string","format":"uuid"}},{"name":"snapshot_id","in":"path","description":"Snapshot ID","required":true,"schema":{"type":"string","format":"uuid"}},{"name":"token","in":"query","description":"Share token (optional)","required":false,"schema":{"type":"string","nullable":true}}],"responses":{"200":{"description":"Snapshot archive","content":{"application/zip":{"schema":{"$ref":"#/components/schemas/DocumentArchiveBinary"}}}},"401":{"description":"Unauthorized"},"404":{"description":"Snapshot not found"}}}},"/api/documents/{id}/snapshots/{snapshot_id}/restore":{"post":{"tags":["Documents"],"operationId":"restore_document_snapshot","parameters":[{"name":"id","in":"path","description":"Document ID","required":true,"schema":{"type":"string","format":"uuid"}},{"name":"snapshot_id","in":"path","description":"Snapshot ID","required":true,"schema":{"type":"string","format":"uuid"}},{"name":"token","in":"query","description":"Share token (optional)","required":false,"schema":{"type":"string","nullable":true}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SnapshotRestoreResponse"}}}}}}},"/api/documents/{id}/unarchive":{"post":{"tags":["Documents"],"operationId":"unarchive_document","parameters":[{"name":"id","in":"path","description":"Document ID","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Document"}}}},"404":{"description":"Document not found"},"409":{"description":"Document is not archived"}}}},"/api/files":{"post":{"tags":["Files"],"summary":"POST /api/files (multipart/form-data)","description":"Fields:\n- file: binary file (required)\n- document_id: uuid (required by current schema)","operationId":"upload_file","requestBody":{"content":{"multipart/form-data":{"schema":{"$ref":"#/components/schemas/UploadFileMultipart"}}},"required":true},"responses":{"201":{"description":"File uploaded","content":{"application/json":{"schema":{"$ref":"#/components/schemas/UploadFileResponse"}}}}}}},"/api/files/documents/{filename}":{"get":{"tags":["Files"],"summary":"GET /api/files/documents/{filename}?document_id=uuid -> bytes","operationId":"get_file_by_name","parameters":[{"name":"filename","in":"path","description":"File name","required":true,"schema":{"type":"string"}},{"name":"document_id","in":"query","description":"Document ID","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"OK","content":{"application/octet-stream":{"schema":{"type":"string","format":"binary"}}}}}}},"/api/files/{id}":{"get":{"tags":["Files"],"summary":"GET /api/files/{id} -> bytes (fallback; primary is /uploads/{filename})","operationId":"get_file","parameters":[{"name":"id","in":"path","description":"File ID","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"OK","content":{"application/octet-stream":{"schema":{"type":"string","format":"binary"}}}}}}},"/api/git/changes":{"get":{"tags":["Git"],"operationId":"get_changes","responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/GitChangesResponse"}}}}}}},"/api/git/config":{"get":{"tags":["Git"],"operationId":"get_config","responses":{"200":{"description":"","content":{"application/json":{"schema":{"allOf":[{"$ref":"#/components/schemas/GitConfigResponse"}],"nullable":true}}}}}},"post":{"tags":["Git"],"operationId":"create_or_update_config","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateGitConfigRequest"}}},"required":true},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/GitConfigResponse"}}}}}},"delete":{"tags":["Git"],"operationId":"delete_config","responses":{"204":{"description":"Deleted"}}}},"/api/git/deinit":{"post":{"tags":["Git"],"operationId":"deinit_repository","responses":{"200":{"description":"OK"}}}},"/api/git/diff/commits/{from}/{to}":{"get":{"tags":["Git"],"operationId":"get_commit_diff","parameters":[{"name":"from","in":"path","description":"From","required":true,"schema":{"type":"string"}},{"name":"to","in":"path","description":"To","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/TextDiffResult"}}}}}}}},"/api/git/diff/working":{"get":{"tags":["Git"],"operationId":"get_working_diff","responses":{"200":{"description":"","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/TextDiffResult"}}}}}}}},"/api/git/gitignore/check":{"post":{"tags":["Git"],"operationId":"check_path_ignored","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CheckIgnoredRequest"}}},"required":true},"responses":{"200":{"description":"OK"}}}},"/api/git/gitignore/patterns":{"get":{"tags":["Git"],"operationId":"get_gitignore_patterns","responses":{"200":{"description":"OK"}}},"post":{"tags":["Git"],"operationId":"add_gitignore_patterns","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/AddPatternsRequest"}}},"required":true},"responses":{"200":{"description":"OK"}}}},"/api/git/history":{"get":{"tags":["Git"],"operationId":"get_history","responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/GitHistoryResponse"}}}}}}},"/api/git/ignore/doc/{id}":{"post":{"tags":["Git"],"operationId":"ignore_document","parameters":[{"name":"id","in":"path","description":"Document ID","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"OK"}}}},"/api/git/ignore/folder/{id}":{"post":{"tags":["Git"],"operationId":"ignore_folder","parameters":[{"name":"id","in":"path","description":"Folder ID","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"OK"}}}},"/api/git/import":{"post":{"tags":["Git"],"operationId":"import_repository","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateGitConfigRequest"}}},"required":true},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/GitImportResponse"}}}}}}},"/api/git/init":{"post":{"tags":["Git"],"operationId":"init_repository","responses":{"200":{"description":"OK"}}}},"/api/git/pull":{"post":{"tags":["Git"],"operationId":"pull_repository","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/GitPullRequest"}}},"required":true},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/GitPullResponse"}}}},"409":{"description":"Conflicts detected","content":{"application/json":{"schema":{"$ref":"#/components/schemas/GitPullResponse"}}}}}}},"/api/git/pull/session/{id}":{"get":{"tags":["Git"],"operationId":"get_pull_session","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/GitPullSessionResponse"}}}}}}},"/api/git/pull/session/{id}/finalize":{"post":{"tags":["Git"],"operationId":"finalize_pull_session","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/GitPullResponse"}}}},"400":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/GitPullResponse"}}}},"409":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/GitPullResponse"}}}}}}},"/api/git/pull/session/{id}/resolve":{"post":{"tags":["Git"],"operationId":"resolve_pull_session","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/GitPullRequest"}}},"required":true},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/GitPullSessionResponse"}}}},"400":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/GitPullSessionResponse"}}}},"409":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/GitPullSessionResponse"}}}}}}},"/api/git/pull/start":{"post":{"tags":["Git"],"operationId":"start_pull_session","responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/GitPullSessionResponse"}}}},"400":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/GitPullSessionResponse"}}}},"409":{"description":"Conflicts detected","content":{"application/json":{"schema":{"$ref":"#/components/schemas/GitPullSessionResponse"}}}}}}},"/api/git/status":{"get":{"tags":["Git"],"operationId":"get_status","responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/GitStatus"}}}}}}},"/api/git/sync":{"post":{"tags":["Git"],"operationId":"sync_now","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/GitSyncRequest"}}},"required":true},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/GitSyncResponse"}}}},"409":{"description":"Conflicts during rebase/pull"}}}},"/api/health":{"get":{"tags":["Health"],"operationId":"health","responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HealthResp"}}}}}}},"/api/markdown/render":{"post":{"tags":["Markdown"],"operationId":"render_markdown","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/RenderRequest"}}},"required":true},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/RenderResponseBody"}}}}}}},"/api/markdown/render-many":{"post":{"tags":["Markdown"],"operationId":"render_markdown_many","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/RenderManyRequest"}}},"required":true},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/RenderManyResponse"}}}}}}},"/api/me/api-tokens":{"get":{"tags":["Auth"],"operationId":"list_api_tokens","responses":{"200":{"description":"","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/ApiTokenItem"}}}}}}},"post":{"tags":["Auth"],"operationId":"create_api_token","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiTokenCreateRequest"}}},"required":true},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiTokenCreateResponse"}}}}}}},"/api/me/api-tokens/{id}":{"delete":{"tags":["Auth"],"operationId":"revoke_api_token","parameters":[{"name":"id","in":"path","description":"Token ID","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"204":{"description":""}}}},"/api/me/plugins/install-from-url":{"post":{"tags":["Plugins"],"operationId":"pluginsInstallFromUrl","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/InstallFromUrlBody"}}},"required":true},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/InstallResponse"}}}}}}},"/api/me/plugins/manifest":{"get":{"tags":["Plugins"],"operationId":"pluginsGetManifest","responses":{"200":{"description":"","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/ManifestItem"}}}}}}}},"/api/me/plugins/uninstall":{"post":{"tags":["Plugins"],"operationId":"pluginsUninstall","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UninstallBody"}}},"required":true},"responses":{"204":{"description":""}}}},"/api/me/plugins/updates":{"get":{"tags":["Plugins"],"operationId":"sse_updates","responses":{"200":{"description":"Plugin event stream"}}}},"/api/me/shortcuts":{"get":{"tags":["Auth"],"operationId":"get_user_shortcuts","responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/UserShortcutResponse"}}}}}},"put":{"tags":["Auth"],"operationId":"update_user_shortcuts","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateUserShortcutRequest"}}},"required":true},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/UserShortcutResponse"}}}}}}},"/api/plugins/{plugin}/docs/{doc_id}/kv/{key}":{"get":{"tags":["Plugins"],"operationId":"pluginsGetKv","parameters":[{"name":"plugin","in":"path","description":"Plugin ID","required":true,"schema":{"type":"string"}},{"name":"doc_id","in":"path","description":"Document ID","required":true,"schema":{"type":"string","format":"uuid"}},{"name":"key","in":"path","description":"Key","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/KvValueResponse"}}}}}},"put":{"tags":["Plugins"],"operationId":"pluginsPutKv","parameters":[{"name":"plugin","in":"path","description":"Plugin ID","required":true,"schema":{"type":"string"}},{"name":"doc_id","in":"path","description":"Document ID","required":true,"schema":{"type":"string","format":"uuid"}},{"name":"key","in":"path","description":"Key","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/KvValueBody"}}},"required":true},"responses":{"204":{"description":""}}}},"/api/plugins/{plugin}/docs/{doc_id}/records/{kind}":{"get":{"tags":["Plugins"],"operationId":"list_records","parameters":[{"name":"plugin","in":"path","description":"Plugin ID","required":true,"schema":{"type":"string"}},{"name":"doc_id","in":"path","description":"Document ID","required":true,"schema":{"type":"string","format":"uuid"}},{"name":"kind","in":"path","description":"Record kind","required":true,"schema":{"type":"string"}},{"name":"limit","in":"query","description":"Limit","required":false,"schema":{"type":"integer","format":"int64","nullable":true}},{"name":"offset","in":"query","description":"Offset","required":false,"schema":{"type":"integer","format":"int64","nullable":true}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/RecordsResponse"}}}}}},"post":{"tags":["Plugins"],"operationId":"pluginsCreateRecord","parameters":[{"name":"plugin","in":"path","description":"Plugin ID","required":true,"schema":{"type":"string"}},{"name":"doc_id","in":"path","description":"Document ID","required":true,"schema":{"type":"string","format":"uuid"}},{"name":"kind","in":"path","description":"Record kind","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateRecordBody"}}},"required":true},"responses":{"200":{"description":"","content":{"application/json":{"schema":{}}}}}}},"/api/plugins/{plugin}/exec/{action}":{"post":{"tags":["Plugins"],"operationId":"pluginsExecAction","parameters":[{"name":"plugin","in":"path","description":"Plugin ID","required":true,"schema":{"type":"string"}},{"name":"action","in":"path","description":"Action","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ExecBody"}}},"required":true},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ExecResultResponse"}}}}}}},"/api/plugins/{plugin}/records/{id}":{"delete":{"tags":["Plugins"],"operationId":"pluginsDeleteRecord","parameters":[{"name":"plugin","in":"path","description":"Plugin ID","required":true,"schema":{"type":"string"}},{"name":"id","in":"path","description":"Record ID","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"204":{"description":""}}},"patch":{"tags":["Plugins"],"operationId":"pluginsUpdateRecord","parameters":[{"name":"plugin","in":"path","description":"Plugin ID","required":true,"schema":{"type":"string"}},{"name":"id","in":"path","description":"Record ID","required":true,"schema":{"type":"string","format":"uuid"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateRecordBody"}}},"required":true},"responses":{"200":{"description":"","content":{"application/json":{"schema":{}}}}}}},"/api/public/documents/{id}":{"get":{"tags":["Public Documents"],"operationId":"get_publish_status","parameters":[{"name":"id","in":"path","description":"Document ID","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"Published status","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PublishResponse"}}}}}},"post":{"tags":["Public Documents"],"operationId":"publish_document","parameters":[{"name":"id","in":"path","description":"Document ID","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"Published","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PublishResponse"}}}}}},"delete":{"tags":["Public Documents"],"operationId":"unpublish_document","parameters":[{"name":"id","in":"path","description":"Document ID","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"204":{"description":"Unpublished"}}}},"/api/public/workspaces/{slug}":{"get":{"tags":["Public Documents"],"operationId":"list_workspace_public_documents","parameters":[{"name":"slug","in":"path","description":"Workspace slug","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"Public documents for workspace","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/PublicDocumentSummary"}}}}}}}},"/api/public/workspaces/{slug}/{id}":{"get":{"tags":["Public Documents"],"operationId":"get_public_by_workspace_and_id","parameters":[{"name":"slug","in":"path","description":"Workspace slug","required":true,"schema":{"type":"string"}},{"name":"id","in":"path","description":"Document ID","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"Document metadata","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Document"}}}}}}},"/api/public/workspaces/{slug}/{id}/content":{"get":{"tags":["Public Documents"],"operationId":"get_public_content_by_workspace_and_id","parameters":[{"name":"slug","in":"path","description":"Workspace slug","required":true,"schema":{"type":"string"}},{"name":"id","in":"path","description":"Document ID","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"Document content"}}}},"/api/shares":{"post":{"tags":["Sharing"],"operationId":"create_share","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateShareRequest"}}},"required":true},"responses":{"200":{"description":"Share link created","content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateShareResponse"}}}}}}},"/api/shares/active":{"get":{"tags":["Sharing"],"operationId":"list_active_shares","responses":{"200":{"description":"Active shares","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/ActiveShareItem"}}}}}}}},"/api/shares/applicable":{"get":{"tags":["Sharing"],"operationId":"list_applicable_shares","parameters":[{"name":"doc_id","in":"query","description":"Document ID","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"Shares that include the document","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/ApplicableShareItem"}}}}}}}},"/api/shares/browse":{"get":{"tags":["Sharing"],"operationId":"browse_share","parameters":[{"name":"token","in":"query","description":"Share token","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"Share tree","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ShareBrowseResponse"}}}}}}},"/api/shares/documents/{id}":{"get":{"tags":["Sharing"],"operationId":"list_document_shares","parameters":[{"name":"id","in":"path","description":"Document ID","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/ShareItem"}}}}}}}},"/api/shares/folders/{token}/materialize":{"post":{"tags":["Sharing"],"operationId":"materialize_folder_share","parameters":[{"name":"token","in":"path","description":"Folder share token","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"Created doc shares","content":{"application/json":{"schema":{"$ref":"#/components/schemas/MaterializeResponse"}}}}}}},"/api/shares/mounts":{"get":{"tags":["Sharing"],"operationId":"list_share_mounts","responses":{"200":{"description":"Share mounts","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/ShareMountItem"}}}}}}},"post":{"tags":["Sharing"],"operationId":"create_share_mount","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateShareMountRequest"}}},"required":true},"responses":{"200":{"description":"Saved share mount","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ShareMountItem"}}}}}}},"/api/shares/mounts/{id}":{"delete":{"tags":["Sharing"],"operationId":"delete_share_mount","parameters":[{"name":"id","in":"path","description":"Share mount ID","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"204":{"description":"Share mount removed"}}}},"/api/shares/validate":{"get":{"tags":["Sharing"],"operationId":"validate_share_token","parameters":[{"name":"token","in":"query","description":"Share token","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"Document info","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ShareDocumentResponse"}}}}}}},"/api/shares/{token}":{"delete":{"tags":["Sharing"],"operationId":"delete_share","parameters":[{"name":"token","in":"path","description":"Share token","required":true,"schema":{"type":"string"}}],"responses":{"204":{"description":"Share link deleted"}}}},"/api/tags":{"get":{"tags":["Tags"],"operationId":"list_tags","parameters":[{"name":"q","in":"query","description":"Filter contains","required":false,"schema":{"type":"string","nullable":true}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/TagItem"}}}}}}}},"/api/workspace-invitations/{token}/accept":{"post":{"tags":["Workspaces"],"operationId":"accept_invitation","parameters":[{"name":"token","in":"path","description":"Invitation token","required":true,"schema":{"type":"string"}}],"responses":{"204":{"description":""}}}},"/api/workspaces":{"get":{"tags":["Workspaces"],"operationId":"list_workspaces","responses":{"200":{"description":"","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/WorkspaceResponse"}}}}}}},"post":{"tags":["Workspaces"],"operationId":"create_workspace","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateWorkspaceRequest"}}},"required":true},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/WorkspaceResponse"}}}}}}},"/api/workspaces/{id}":{"get":{"tags":["Workspaces"],"operationId":"get_workspace_detail","parameters":[{"name":"id","in":"path","description":"Workspace ID","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/WorkspaceResponse"}}}}}},"put":{"tags":["Workspaces"],"operationId":"update_workspace","parameters":[{"name":"id","in":"path","description":"Workspace ID","required":true,"schema":{"type":"string","format":"uuid"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateWorkspaceRequest"}}},"required":true},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/WorkspaceResponse"}}}}}},"delete":{"tags":["Workspaces"],"operationId":"delete_workspace","parameters":[{"name":"id","in":"path","description":"Workspace ID","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"204":{"description":""}}}},"/api/workspaces/{id}/download":{"get":{"tags":["Workspaces"],"operationId":"download_workspace_archive","parameters":[{"name":"id","in":"path","description":"Workspace ID","required":true,"schema":{"type":"string","format":"uuid"}},{"name":"format","in":"query","description":"Download format (archive only)","required":false,"schema":{"allOf":[{"$ref":"#/components/schemas/DownloadFormat"}],"nullable":true}}],"responses":{"200":{"description":"Workspace download","content":{"application/octet-stream":{"schema":{"$ref":"#/components/schemas/DocumentDownloadBinary"}}}},"401":{"description":"Unauthorized"},"404":{"description":"Workspace not found"}}}},"/api/workspaces/{id}/invitations":{"get":{"tags":["Workspaces"],"operationId":"list_invitations","parameters":[{"name":"id","in":"path","description":"Workspace ID","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/WorkspaceInvitationResponse"}}}}}}},"post":{"tags":["Workspaces"],"operationId":"create_invitation","parameters":[{"name":"id","in":"path","description":"Workspace ID","required":true,"schema":{"type":"string","format":"uuid"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateWorkspaceInvitationRequest"}}},"required":true},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/WorkspaceInvitationResponse"}}}}}}},"/api/workspaces/{id}/invitations/{invitation_id}":{"delete":{"tags":["Workspaces"],"operationId":"revoke_invitation","parameters":[{"name":"id","in":"path","description":"Workspace ID","required":true,"schema":{"type":"string","format":"uuid"}},{"name":"invitation_id","in":"path","description":"Invitation ID","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/WorkspaceInvitationResponse"}}}}}}},"/api/workspaces/{id}/members":{"get":{"tags":["Workspaces"],"operationId":"list_members","parameters":[{"name":"id","in":"path","description":"Workspace ID","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/WorkspaceMemberResponse"}}}}}}}},"/api/workspaces/{id}/members/{user_id}":{"delete":{"tags":["Workspaces"],"operationId":"remove_member","parameters":[{"name":"id","in":"path","description":"Workspace ID","required":true,"schema":{"type":"string","format":"uuid"}},{"name":"user_id","in":"path","description":"Target user ID","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"204":{"description":""}}},"patch":{"tags":["Workspaces"],"operationId":"update_member_role","parameters":[{"name":"id","in":"path","description":"Workspace ID","required":true,"schema":{"type":"string","format":"uuid"}},{"name":"user_id","in":"path","description":"Target user ID","required":true,"schema":{"type":"string","format":"uuid"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateMemberRoleRequest"}}},"required":true},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/WorkspaceMemberResponse"}}}}}}},"/api/workspaces/{id}/permissions":{"get":{"tags":["Workspaces"],"operationId":"get_workspace_permissions","parameters":[{"name":"id","in":"path","description":"Workspace ID","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/WorkspacePermissionsResponse"}}}}}}},"/api/workspaces/{id}/roles":{"get":{"tags":["Workspaces"],"operationId":"list_roles","parameters":[{"name":"id","in":"path","description":"Workspace ID","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/WorkspaceRoleResponse"}}}}}}},"post":{"tags":["Workspaces"],"operationId":"create_role","parameters":[{"name":"id","in":"path","description":"Workspace ID","required":true,"schema":{"type":"string","format":"uuid"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateWorkspaceRoleRequest"}}},"required":true},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/WorkspaceRoleResponse"}}}}}}},"/api/workspaces/{id}/roles/{role_id}":{"delete":{"tags":["Workspaces"],"operationId":"delete_role","parameters":[{"name":"id","in":"path","description":"Workspace ID","required":true,"schema":{"type":"string","format":"uuid"}},{"name":"role_id","in":"path","description":"Role ID","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"204":{"description":""}}},"patch":{"tags":["Workspaces"],"operationId":"update_role","parameters":[{"name":"id","in":"path","description":"Workspace ID","required":true,"schema":{"type":"string","format":"uuid"}},{"name":"role_id","in":"path","description":"Role ID","required":true,"schema":{"type":"string","format":"uuid"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateWorkspaceRoleRequest"}}},"required":true},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/WorkspaceRoleResponse"}}}}}}},"/api/workspaces/{id}/switch":{"post":{"tags":["Workspaces"],"operationId":"switch_workspace","parameters":[{"name":"id","in":"path","description":"Workspace ID","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SwitchWorkspaceResponse"}}}}}}},"/api/yjs/{id}":{"get":{"tags":["Realtime"],"operationId":"axum_ws_entry","parameters":[{"name":"id","in":"path","description":"Document ID (UUID)","required":true,"schema":{"type":"string"}},{"name":"token","in":"query","description":"JWT or share token","required":false,"schema":{"type":"string","nullable":true}},{"name":"Authorization","in":"header","description":"Bearer token (JWT or share token)","required":false,"schema":{"type":"string","nullable":true}}],"responses":{"101":{"description":"Switching Protocols (WebSocket upgrade)"},"401":{"description":"Unauthorized"}}}}},"components":{"schemas":{"ActiveShareItem":{"type":"object","required":["id","token","permission","created_at","document_id","document_title","document_type","url"],"properties":{"created_at":{"type":"string","format":"date-time"},"document_id":{"type":"string","format":"uuid"},"document_title":{"type":"string"},"document_type":{"type":"string","description":"'document' or 'folder'"},"expires_at":{"type":"string","format":"date-time","nullable":true},"id":{"type":"string","format":"uuid"},"parent_share_id":{"type":"string","format":"uuid","nullable":true},"permission":{"type":"string"},"token":{"type":"string"},"url":{"type":"string"}}},"AddPatternsRequest":{"type":"object","required":["patterns"],"properties":{"patterns":{"type":"array","items":{"type":"string"}}}},"ApiTokenCreateRequest":{"type":"object","properties":{"name":{"type":"string","example":"Deploy token","nullable":true}}},"ApiTokenCreateResponse":{"type":"object","required":["id","name","created_at","token"],"properties":{"created_at":{"type":"string","format":"date-time"},"id":{"type":"string","format":"uuid"},"name":{"type":"string"},"token":{"type":"string"}}},"ApiTokenItem":{"type":"object","required":["id","name","created_at"],"properties":{"created_at":{"type":"string","format":"date-time"},"id":{"type":"string","format":"uuid"},"last_used_at":{"type":"string","format":"date-time","nullable":true},"name":{"type":"string"},"revoked_at":{"type":"string","format":"date-time","nullable":true}}},"ApplicableShareItem":{"type":"object","required":["token","permission","scope","excluded"],"properties":{"excluded":{"type":"boolean"},"permission":{"type":"string"},"scope":{"type":"string","description":"'document' or 'folder'"},"token":{"type":"string"}}},"AuthProviderInfoResponse":{"type":"object","required":["id","requires_state","client_ids"],"properties":{"authorization_url":{"type":"string","nullable":true},"client_ids":{"type":"array","items":{"type":"string"}},"id":{"type":"string"},"name":{"type":"string","nullable":true},"redirect_uri":{"type":"string","nullable":true},"requires_state":{"type":"boolean"},"scopes":{"type":"array","items":{"type":"string"}}}},"AuthProvidersResponse":{"type":"object","required":["providers"],"properties":{"providers":{"type":"array","items":{"$ref":"#/components/schemas/AuthProviderInfoResponse"}}}},"BacklinkInfo":{"type":"object","required":["document_id","title","document_type","link_type","link_count"],"properties":{"document_id":{"type":"string"},"document_type":{"type":"string"},"file_path":{"type":"string","nullable":true},"link_count":{"type":"integer","format":"int64"},"link_text":{"type":"string","nullable":true},"link_type":{"type":"string"},"title":{"type":"string"}}},"BacklinksResponse":{"type":"object","required":["backlinks","total_count"],"properties":{"backlinks":{"type":"array","items":{"$ref":"#/components/schemas/BacklinkInfo"}},"total_count":{"type":"integer","minimum":0}}},"CheckIgnoredRequest":{"type":"object","required":["path"],"properties":{"path":{"type":"string"}}},"CreateDocumentRequest":{"type":"object","properties":{"parent_id":{"type":"string","format":"uuid","nullable":true},"title":{"type":"string","nullable":true},"type":{"type":"string","nullable":true}}},"CreateGitConfigRequest":{"type":"object","required":["repository_url","auth_type","auth_data"],"properties":{"auth_data":{},"auth_type":{"type":"string"},"auto_sync":{"type":"boolean","nullable":true},"branch_name":{"type":"string","nullable":true},"repository_url":{"type":"string"}}},"CreateRecordBody":{"type":"object","required":["data"],"properties":{"data":{}}},"CreateShareMountRequest":{"type":"object","required":["token"],"properties":{"parent_folder_id":{"type":"string","format":"uuid","nullable":true},"token":{"type":"string"}}},"CreateShareRequest":{"type":"object","required":["document_id"],"properties":{"document_id":{"type":"string","format":"uuid"},"expires_at":{"type":"string","format":"date-time","nullable":true},"permission":{"type":"string","nullable":true}}},"CreateShareResponse":{"type":"object","required":["token","url"],"properties":{"token":{"type":"string"},"url":{"type":"string"}}},"CreateWorkspaceInvitationRequest":{"type":"object","required":["email","role_kind"],"properties":{"custom_role_id":{"type":"string","format":"uuid","nullable":true},"email":{"type":"string"},"expires_at":{"type":"string","format":"date-time","nullable":true},"role_kind":{"type":"string"},"system_role":{"type":"string","nullable":true}}},"CreateWorkspaceRequest":{"type":"object","required":["name"],"properties":{"description":{"type":"string","nullable":true},"icon":{"type":"string","nullable":true},"name":{"type":"string"}}},"CreateWorkspaceRoleRequest":{"type":"object","required":["name","base_role"],"properties":{"base_role":{"type":"string"},"description":{"type":"string","nullable":true},"name":{"type":"string"},"overrides":{"type":"array","items":{"$ref":"#/components/schemas/PermissionOverridePayload"},"nullable":true},"priority":{"type":"integer","format":"int32","nullable":true}}},"Document":{"type":"object","required":["id","owner_id","workspace_id","title","type","created_at","updated_at","slug","desired_path"],"properties":{"archived_at":{"type":"string","format":"date-time","nullable":true},"archived_by":{"type":"string","format":"uuid","nullable":true},"archived_parent_id":{"type":"string","format":"uuid","nullable":true},"created_at":{"type":"string","format":"date-time"},"created_by":{"type":"string","format":"uuid","nullable":true},"created_by_plugin":{"type":"string","nullable":true},"desired_path":{"type":"string"},"id":{"type":"string","format":"uuid"},"owner_id":{"type":"string","format":"uuid"},"parent_id":{"type":"string","format":"uuid","nullable":true},"path":{"type":"string","nullable":true},"slug":{"type":"string"},"title":{"type":"string"},"type":{"type":"string"},"updated_at":{"type":"string","format":"date-time"},"workspace_id":{"type":"string","format":"uuid"}}},"DocumentArchiveBinary":{"type":"string","format":"binary"},"DocumentDownloadBinary":{"type":"string","format":"binary"},"DocumentListResponse":{"type":"object","required":["items"],"properties":{"items":{"type":"array","items":{"$ref":"#/components/schemas/Document"}}}},"DocumentPatchOperationRequest":{"oneOf":[{"type":"object","required":["offset","text","op"],"properties":{"offset":{"type":"integer","minimum":0},"op":{"type":"string","enum":["insert"]},"text":{"type":"string"}}},{"type":"object","required":["offset","length","op"],"properties":{"length":{"type":"integer","minimum":0},"offset":{"type":"integer","minimum":0},"op":{"type":"string","enum":["delete"]}}},{"type":"object","required":["offset","length","text","op"],"properties":{"length":{"type":"integer","minimum":0},"offset":{"type":"integer","minimum":0},"op":{"type":"string","enum":["replace"]},"text":{"type":"string"}}}],"discriminator":{"propertyName":"op"}},"DownloadDocumentQuery":{"type":"object","properties":{"format":{"$ref":"#/components/schemas/DownloadFormat"},"token":{"type":"string","nullable":true}}},"DownloadFormat":{"type":"string","enum":["archive","markdown","html","html5","pdf","docx","latex","beamer","context","man","mediawiki","dokuwiki","textile","org","texinfo","opml","docbook","opendocument","odt","rtf","epub","epub3","fb2","asciidoc","icml","slidy","slideous","dzslides","revealjs","s5","json","plain","commonmark","commonmark_x","markdown_strict","markdown_phpextra","markdown_github","rst","native","haddock"]},"DownloadWorkspaceQuery":{"type":"object","properties":{"format":{"$ref":"#/components/schemas/DownloadFormat"}}},"DuplicateDocumentRequest":{"type":"object","properties":{"parent_id":{"type":"string","nullable":true},"title":{"type":"string","nullable":true}}},"ExecBody":{"type":"object","properties":{"payload":{"nullable":true}}},"ExecResultResponse":{"type":"object","required":["ok","effects"],"properties":{"data":{"nullable":true},"effects":{"type":"array","items":{}},"error":{"nullable":true},"ok":{"type":"boolean"}}},"GitChangeItem":{"type":"object","required":["path","status"],"properties":{"path":{"type":"string"},"status":{"type":"string"}}},"GitChangesResponse":{"type":"object","required":["files"],"properties":{"files":{"type":"array","items":{"$ref":"#/components/schemas/GitChangeItem"}}}},"GitCommitItem":{"type":"object","required":["hash","message","author_name","author_email","time"],"properties":{"author_email":{"type":"string"},"author_name":{"type":"string"},"hash":{"type":"string"},"message":{"type":"string"},"time":{"type":"string","format":"date-time"}}},"GitConfigResponse":{"type":"object","required":["id","repository_url","branch_name","auth_type","auto_sync","created_at","updated_at"],"properties":{"auth_type":{"type":"string"},"auto_sync":{"type":"boolean"},"branch_name":{"type":"string"},"created_at":{"type":"string","format":"date-time"},"id":{"type":"string","format":"uuid"},"remote_check":{"allOf":[{"$ref":"#/components/schemas/GitRemoteCheckResponse"}],"nullable":true},"repository_url":{"type":"string"},"updated_at":{"type":"string","format":"date-time"}}},"GitHistoryResponse":{"type":"object","required":["commits"],"properties":{"commits":{"type":"array","items":{"$ref":"#/components/schemas/GitCommitItem"}}}},"GitImportResponse":{"type":"object","required":["success","message","files_changed","docs_created","attachments_created"],"properties":{"attachments_created":{"type":"integer","format":"int32"},"commit_hash":{"type":"string","nullable":true},"docs_created":{"type":"integer","format":"int32"},"files_changed":{"type":"integer","format":"int32"},"message":{"type":"string"},"success":{"type":"boolean"}}},"GitPullConflictItem":{"type":"object","required":["path","is_binary"],"properties":{"base":{"type":"string","nullable":true},"document_id":{"type":"string","format":"uuid","nullable":true},"is_binary":{"type":"boolean"},"ours":{"type":"string","nullable":true},"path":{"type":"string"},"theirs":{"type":"string","nullable":true}}},"GitPullRequest":{"type":"object","properties":{"resolutions":{"type":"array","items":{"$ref":"#/components/schemas/GitPullResolution"},"nullable":true}}},"GitPullResolution":{"type":"object","required":["path","choice"],"properties":{"choice":{"type":"string"},"content":{"type":"string","nullable":true},"path":{"type":"string"}}},"GitPullResponse":{"type":"object","required":["success","message","files_changed"],"properties":{"commit_hash":{"type":"string","nullable":true},"conflicts":{"type":"array","items":{"$ref":"#/components/schemas/GitPullConflictItem"},"nullable":true},"files_changed":{"type":"integer","format":"int32"},"git_status":{"allOf":[{"$ref":"#/components/schemas/GitStatus"}],"nullable":true},"message":{"type":"string"},"success":{"type":"boolean"}}},"GitPullSessionResponse":{"type":"object","required":["session_id","status","conflicts","resolutions"],"properties":{"conflicts":{"type":"array","items":{"$ref":"#/components/schemas/GitPullConflictItem"}},"message":{"type":"string","nullable":true},"resolutions":{"type":"array","items":{"$ref":"#/components/schemas/GitPullResolution"}},"session_id":{"type":"string","format":"uuid"},"status":{"type":"string"}}},"GitRemoteCheckResponse":{"type":"object","required":["ok","message"],"properties":{"message":{"type":"string"},"ok":{"type":"boolean"},"reason":{"type":"string","nullable":true}}},"GitStatus":{"type":"object","required":["repository_initialized","has_remote","uncommitted_changes","untracked_files","sync_enabled"],"properties":{"current_branch":{"type":"string","nullable":true},"has_remote":{"type":"boolean"},"last_sync":{"type":"string","format":"date-time","nullable":true},"last_sync_commit_hash":{"type":"string","nullable":true},"last_sync_message":{"type":"string","nullable":true},"last_sync_status":{"type":"string","nullable":true},"repository_initialized":{"type":"boolean"},"sync_enabled":{"type":"boolean"},"uncommitted_changes":{"type":"integer","format":"int32","minimum":0},"untracked_files":{"type":"integer","format":"int32","minimum":0}}},"GitSyncRequest":{"type":"object","properties":{"force":{"type":"boolean","nullable":true},"full_scan":{"type":"boolean","nullable":true},"message":{"type":"string","nullable":true},"skip_push":{"type":"boolean","nullable":true}}},"GitSyncResponse":{"type":"object","required":["success","message","files_changed"],"properties":{"commit_hash":{"type":"string","nullable":true},"files_changed":{"type":"integer","format":"int32","minimum":0},"message":{"type":"string"},"success":{"type":"boolean"}}},"HealthResp":{"type":"object","required":["status"],"properties":{"status":{"type":"string"}}},"InstallFromUrlBody":{"type":"object","required":["url"],"properties":{"token":{"type":"string","nullable":true},"url":{"type":"string"}}},"InstallResponse":{"type":"object","required":["id","version"],"properties":{"id":{"type":"string"},"version":{"type":"string"}}},"KvValueBody":{"type":"object","required":["value"],"properties":{"value":{}}},"KvValueResponse":{"type":"object","required":["value"],"properties":{"value":{}}},"LoginRequest":{"type":"object","required":["email","password"],"properties":{"email":{"type":"string"},"password":{"type":"string"},"remember_me":{"type":"boolean"}}},"LoginResponse":{"type":"object","required":["access_token","user"],"properties":{"access_token":{"type":"string"},"user":{"$ref":"#/components/schemas/UserResponse"}}},"ManifestItem":{"type":"object","required":["id","version","scope","mounts","frontend","permissions","config","ui"],"properties":{"author":{"type":"string","nullable":true},"config":{},"frontend":{},"id":{"type":"string"},"mounts":{"type":"array","items":{"type":"string"}},"name":{"type":"string","nullable":true},"permissions":{"type":"array","items":{"type":"string"}},"repository":{"type":"string","nullable":true},"scope":{"type":"string"},"ui":{},"version":{"type":"string"}}},"MaterializeResponse":{"type":"object","required":["created"],"properties":{"created":{"type":"integer","format":"int64"}}},"OAuthLoginRequest":{"type":"object","properties":{"code":{"type":"string","nullable":true},"credential":{"type":"string","nullable":true},"redirect_uri":{"type":"string","nullable":true},"remember_me":{"type":"boolean"},"state":{"type":"string","nullable":true}}},"OAuthStateResponse":{"type":"object","required":["state"],"properties":{"state":{"type":"string"}}},"OutgoingLink":{"type":"object","required":["document_id","title","document_type","link_type"],"properties":{"document_id":{"type":"string"},"document_type":{"type":"string"},"file_path":{"type":"string","nullable":true},"link_text":{"type":"string","nullable":true},"link_type":{"type":"string"},"position_end":{"type":"integer","format":"int32","nullable":true},"position_start":{"type":"integer","format":"int32","nullable":true},"title":{"type":"string"}}},"OutgoingLinksResponse":{"type":"object","required":["links","total_count"],"properties":{"links":{"type":"array","items":{"$ref":"#/components/schemas/OutgoingLink"}},"total_count":{"type":"integer","minimum":0}}},"PatchDocumentContentRequest":{"type":"object","required":["operations"],"properties":{"operations":{"type":"array","items":{"$ref":"#/components/schemas/DocumentPatchOperationRequest"}}}},"PermissionOverridePayload":{"type":"object","required":["permission","allowed"],"properties":{"allowed":{"type":"boolean"},"permission":{"type":"string"}}},"PlaceholderItemPayload":{"type":"object","required":["kind","id","code"],"properties":{"code":{"type":"string"},"id":{"type":"string"},"kind":{"type":"string"}}},"PublicDocumentSummary":{"type":"object","required":["id","title","updated_at","published_at"],"properties":{"id":{"type":"string","format":"uuid"},"published_at":{"type":"string","format":"date-time"},"title":{"type":"string"},"updated_at":{"type":"string","format":"date-time"}}},"PublishResponse":{"type":"object","required":["slug","public_url"],"properties":{"public_url":{"type":"string"},"slug":{"type":"string"}}},"RecordsResponse":{"type":"object","required":["items"],"properties":{"items":{"type":"array","items":{}}}},"RefreshResponse":{"type":"object","required":["access_token"],"properties":{"access_token":{"type":"string"}}},"RegisterRequest":{"type":"object","required":["email","name","password"],"properties":{"email":{"type":"string"},"name":{"type":"string"},"password":{"type":"string"}}},"RenderManyRequest":{"type":"object","required":["items"],"properties":{"items":{"type":"array","items":{"$ref":"#/components/schemas/RenderRequest"}}}},"RenderManyResponse":{"type":"object","required":["items"],"properties":{"items":{"type":"array","items":{"$ref":"#/components/schemas/RenderResponseBody"}}}},"RenderOptionsPayload":{"type":"object","properties":{"absolute_attachments":{"type":"boolean","default":null,"nullable":true},"base_origin":{"type":"string","default":null,"nullable":true},"doc_id":{"type":"string","format":"uuid","default":null,"nullable":true},"features":{"type":"array","items":{"type":"string"},"default":null,"nullable":true},"flavor":{"type":"string","default":null,"nullable":true},"hardbreaks":{"type":"boolean","default":null,"nullable":true},"sanitize":{"type":"boolean","default":null,"nullable":true},"theme":{"type":"string","default":null,"nullable":true},"token":{"type":"string","default":null,"nullable":true}}},"RenderRequest":{"type":"object","required":["text"],"properties":{"options":{"$ref":"#/components/schemas/RenderOptionsPayload"},"text":{"type":"string"}}},"RenderResponseBody":{"type":"object","required":["html","hash"],"properties":{"hash":{"type":"string"},"html":{"type":"string"},"placeholders":{"type":"array","items":{"$ref":"#/components/schemas/PlaceholderItemPayload"}}}},"SearchResult":{"type":"object","required":["id","title","document_type","updated_at"],"properties":{"document_type":{"type":"string"},"id":{"type":"string","format":"uuid"},"path":{"type":"string","nullable":true},"title":{"type":"string"},"updated_at":{"type":"string","format":"date-time"}}},"SessionResponse":{"type":"object","required":["id","workspace_id","remember_me","created_at","last_seen_at","expires_at","current"],"properties":{"created_at":{"type":"string","format":"date-time"},"current":{"type":"boolean"},"expires_at":{"type":"string","format":"date-time"},"id":{"type":"string","format":"uuid"},"ip_address":{"type":"string","nullable":true},"last_seen_at":{"type":"string","format":"date-time"},"remember_me":{"type":"boolean"},"user_agent":{"type":"string","nullable":true},"workspace_id":{"type":"string","format":"uuid"}}},"ShareBrowseResponse":{"type":"object","required":["tree"],"properties":{"tree":{"type":"array","items":{"$ref":"#/components/schemas/ShareBrowseTreeItem"}}}},"ShareBrowseTreeItem":{"type":"object","required":["id","title","type","created_at","updated_at"],"properties":{"created_at":{"type":"string","format":"date-time"},"id":{"type":"string","format":"uuid"},"parent_id":{"type":"string","format":"uuid","nullable":true},"title":{"type":"string"},"type":{"type":"string","example":"document"},"updated_at":{"type":"string","format":"date-time"}}},"ShareDocumentResponse":{"type":"object","required":["id","title","permission"],"properties":{"content":{"type":"string","nullable":true},"id":{"type":"string","format":"uuid"},"permission":{"type":"string"},"title":{"type":"string"}}},"ShareItem":{"type":"object","required":["id","token","permission","url","scope"],"properties":{"expires_at":{"type":"string","format":"date-time","nullable":true},"id":{"type":"string","format":"uuid"},"parent_share_id":{"type":"string","format":"uuid","description":"If present, this document share was materialized from a folder share","nullable":true},"permission":{"type":"string"},"scope":{"type":"string","description":"document | folder"},"token":{"type":"string"},"url":{"type":"string"}}},"ShareMountItem":{"type":"object","required":["id","token","target_document_id","target_document_type","target_title","permission","created_at"],"properties":{"created_at":{"type":"string","format":"date-time"},"id":{"type":"string","format":"uuid"},"parent_folder_id":{"type":"string","format":"uuid","nullable":true},"permission":{"type":"string"},"target_document_id":{"type":"string","format":"uuid"},"target_document_type":{"type":"string"},"target_title":{"type":"string"},"token":{"type":"string"}}},"SnapshotDiffBaseParam":{"type":"string","enum":["auto","current","previous"]},"SnapshotDiffKind":{"type":"string","enum":["current","snapshot"]},"SnapshotDiffResponse":{"type":"object","required":["base","target","diff"],"properties":{"base":{"$ref":"#/components/schemas/SnapshotDiffSideResponse"},"diff":{"$ref":"#/components/schemas/TextDiffResult"},"target":{"$ref":"#/components/schemas/SnapshotDiffSideResponse"}}},"SnapshotDiffSideResponse":{"type":"object","required":["kind","markdown"],"properties":{"kind":{"$ref":"#/components/schemas/SnapshotDiffKind"},"markdown":{"type":"string"},"snapshot":{"allOf":[{"$ref":"#/components/schemas/SnapshotSummary"}],"nullable":true}}},"SnapshotListResponse":{"type":"object","required":["items"],"properties":{"items":{"type":"array","items":{"$ref":"#/components/schemas/SnapshotSummary"}}}},"SnapshotRestoreResponse":{"type":"object","required":["snapshot"],"properties":{"snapshot":{"$ref":"#/components/schemas/SnapshotSummary"}}},"SnapshotSummary":{"type":"object","required":["id","document_id","label","kind","created_at","byte_size","content_hash"],"properties":{"byte_size":{"type":"integer","format":"int64"},"content_hash":{"type":"string"},"created_at":{"type":"string","format":"date-time"},"created_by":{"type":"string","format":"uuid","nullable":true},"document_id":{"type":"string","format":"uuid"},"id":{"type":"string","format":"uuid"},"kind":{"type":"string"},"label":{"type":"string"},"notes":{"type":"string","nullable":true}}},"SwitchWorkspaceResponse":{"type":"object","required":["access_token"],"properties":{"access_token":{"type":"string"}}},"TagItem":{"type":"object","required":["name","count"],"properties":{"count":{"type":"integer","format":"int64"},"name":{"type":"string"}}},"TextDiffLine":{"type":"object","required":["line_type","content"],"properties":{"content":{"type":"string"},"line_type":{"$ref":"#/components/schemas/TextDiffLineType"},"new_line_number":{"type":"integer","format":"int32","nullable":true,"minimum":0},"old_line_number":{"type":"integer","format":"int32","nullable":true,"minimum":0}}},"TextDiffLineType":{"type":"string","enum":["added","deleted","context"]},"TextDiffResult":{"type":"object","required":["file_path","diff_lines"],"properties":{"diff_lines":{"type":"array","items":{"$ref":"#/components/schemas/TextDiffLine"}},"file_path":{"type":"string"},"new_content":{"type":"string","nullable":true},"old_content":{"type":"string","nullable":true}}},"UninstallBody":{"type":"object","required":["id"],"properties":{"id":{"type":"string"}}},"UpdateDocumentContentRequest":{"type":"object","required":["content"],"properties":{"content":{"type":"string"}}},"UpdateDocumentRequest":{"type":"object","properties":{"parent_id":{"type":"string","nullable":true},"title":{"type":"string","nullable":true}}},"UpdateGitConfigRequest":{"type":"object","properties":{"auth_data":{"nullable":true},"auth_type":{"type":"string","nullable":true},"auto_sync":{"type":"boolean","nullable":true},"branch_name":{"type":"string","nullable":true},"repository_url":{"type":"string","nullable":true}}},"UpdateMemberRoleRequest":{"type":"object","required":["role_kind"],"properties":{"custom_role_id":{"type":"string","format":"uuid","nullable":true},"role_kind":{"type":"string"},"system_role":{"type":"string","nullable":true}}},"UpdateRecordBody":{"type":"object","required":["patch"],"properties":{"patch":{}}},"UpdateUserShortcutRequest":{"type":"object","properties":{"bindings":{"type":"object"},"leader_key":{"type":"string","example":"","nullable":true}}},"UpdateWorkspaceRequest":{"type":"object","properties":{"description":{"type":"string","nullable":true},"icon":{"type":"string","nullable":true},"name":{"type":"string","nullable":true}}},"UpdateWorkspaceRoleRequest":{"type":"object","properties":{"base_role":{"type":"string","nullable":true},"description":{"type":"string","nullable":true},"name":{"type":"string","nullable":true},"overrides":{"type":"array","items":{"$ref":"#/components/schemas/PermissionOverridePayload"},"nullable":true},"priority":{"type":"integer","format":"int32","nullable":true}}},"UploadFileMultipart":{"type":"object","required":["file","document_id"],"properties":{"document_id":{"type":"string","format":"uuid","description":"Target document ID"},"file":{"type":"string","format":"binary","description":"File to upload"}}},"UploadFileResponse":{"type":"object","required":["id","url","filename","size"],"properties":{"content_type":{"type":"string","nullable":true},"filename":{"type":"string"},"id":{"type":"string","format":"uuid"},"size":{"type":"integer","format":"int64"},"url":{"type":"string"}}},"UserResponse":{"type":"object","required":["id","email","name","workspaces"],"properties":{"active_workspace":{"allOf":[{"$ref":"#/components/schemas/WorkspaceMembershipResponse"}],"nullable":true},"active_workspace_id":{"type":"string","format":"uuid","nullable":true},"active_workspace_permissions":{"type":"array","items":{"type":"string"}},"email":{"type":"string"},"id":{"type":"string","format":"uuid"},"name":{"type":"string"},"workspaces":{"type":"array","items":{"$ref":"#/components/schemas/WorkspaceMembershipResponse"}}}},"UserShortcutResponse":{"type":"object","required":["bindings"],"properties":{"bindings":{"type":"object"},"leader_key":{"type":"string","example":"","nullable":true},"updated_at":{"type":"string","format":"date-time","nullable":true}}},"WorkspaceInvitationResponse":{"type":"object","required":["id","workspace_id","email","role_kind","invited_by","token","created_at"],"properties":{"accepted_at":{"type":"string","format":"date-time","nullable":true},"accepted_by":{"type":"string","format":"uuid","nullable":true},"created_at":{"type":"string","format":"date-time"},"custom_role_id":{"type":"string","format":"uuid","nullable":true},"email":{"type":"string"},"expires_at":{"type":"string","format":"date-time","nullable":true},"id":{"type":"string","format":"uuid"},"invited_by":{"type":"string","format":"uuid"},"revoked_at":{"type":"string","format":"date-time","nullable":true},"role_kind":{"type":"string"},"system_role":{"type":"string","nullable":true},"token":{"type":"string"},"workspace_id":{"type":"string","format":"uuid"}}},"WorkspaceMemberResponse":{"type":"object","required":["workspace_id","user_id","email","name","role_kind","is_default"],"properties":{"custom_role_id":{"type":"string","format":"uuid","nullable":true},"email":{"type":"string"},"is_default":{"type":"boolean"},"name":{"type":"string"},"role_kind":{"type":"string"},"system_role":{"type":"string","nullable":true},"user_id":{"type":"string","format":"uuid"},"workspace_id":{"type":"string","format":"uuid"}}},"WorkspaceMembershipResponse":{"type":"object","required":["id","name","slug","is_personal","role_kind","is_default"],"properties":{"custom_role_id":{"type":"string","format":"uuid","nullable":true},"description":{"type":"string","nullable":true},"icon":{"type":"string","nullable":true},"id":{"type":"string","format":"uuid"},"is_default":{"type":"boolean"},"is_personal":{"type":"boolean"},"name":{"type":"string"},"role_kind":{"type":"string"},"slug":{"type":"string"},"system_role":{"type":"string","nullable":true}}},"WorkspacePermissionsResponse":{"type":"object","required":["workspace_id","permissions"],"properties":{"permissions":{"type":"array","items":{"type":"string"}},"workspace_id":{"type":"string","format":"uuid"}}},"WorkspaceResponse":{"type":"object","required":["id","name","slug","is_personal","role_kind","is_default"],"properties":{"custom_role_id":{"type":"string","format":"uuid","nullable":true},"description":{"type":"string","nullable":true},"icon":{"type":"string","nullable":true},"id":{"type":"string","format":"uuid"},"is_default":{"type":"boolean"},"is_personal":{"type":"boolean"},"name":{"type":"string"},"role_kind":{"type":"string"},"slug":{"type":"string"},"system_role":{"type":"string","nullable":true}}},"WorkspaceRoleResponse":{"type":"object","required":["id","workspace_id","name","base_role","priority","overrides"],"properties":{"base_role":{"type":"string"},"description":{"type":"string","nullable":true},"id":{"type":"string","format":"uuid"},"name":{"type":"string"},"overrides":{"type":"array","items":{"$ref":"#/components/schemas/PermissionOverridePayload"}},"priority":{"type":"integer","format":"int32"},"workspace_id":{"type":"string","format":"uuid"}}}}},"tags":[{"name":"Auth","description":"Authentication"},{"name":"Documents","description":"Documents management"},{"name":"Files","description":"File management"},{"name":"Sharing","description":"Document sharing"},{"name":"Public Documents","description":"Public pages"},{"name":"Realtime","description":"Yjs WebSocket endpoint (/yjs/:id)"},{"name":"Git","description":"Git integration"},{"name":"Markdown","description":"Markdown rendering"},{"name":"Plugins","description":"Plugins management & data APIs"},{"name":"Health","description":"System health checks"}]} diff --git a/api/src/application/dto/git.rs b/api/src/application/dto/git.rs index 85ca3a16..76bea535 100644 --- a/api/src/application/dto/git.rs +++ b/api/src/application/dto/git.rs @@ -86,8 +86,63 @@ pub struct GitSyncOutcome { pub message: String, } +#[derive(Debug, Clone)] +pub struct GitImportOutcome { + pub files_changed: u32, + pub commit_hash: Option, + pub docs_created: u32, + pub attachments_created: u32, + pub message: String, +} + #[derive(Debug, Clone)] pub struct GitignoreUpdateDto { pub added: usize, pub patterns: Vec, } + +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +pub struct GitPullResolutionDto { + pub path: String, + /// one of: ours, theirs, custom_text + pub choice: String, + pub content: Option, +} + +#[derive(Debug, Clone)] +pub struct GitPullRequestDto { + pub resolutions: Vec, +} + +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +pub struct GitPullConflictItemDto { + pub path: String, + pub is_binary: bool, + pub ours: Option, + pub theirs: Option, + pub base: Option, + pub document_id: Option, +} + +#[derive(Debug, Clone)] +pub struct GitPullResultDto { + pub success: bool, + pub message: String, + pub files_changed: u32, + pub commit_hash: Option, + pub conflicts: Option>, + pub base_commit: Option>, + pub remote_commit: Option>, +} + +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +pub struct GitPullSessionDto { + pub id: uuid::Uuid, + pub workspace_id: uuid::Uuid, + pub status: String, + pub conflicts: Vec, + pub resolutions: Vec, + pub message: Option, + pub base_commit: Option>, + pub remote_commit: Option>, +} diff --git a/api/src/application/mod.rs b/api/src/application/mod.rs index a00a3bc0..ef51706e 100644 --- a/api/src/application/mod.rs +++ b/api/src/application/mod.rs @@ -4,3 +4,4 @@ pub mod linkgraph; pub mod ports; pub mod services; pub mod use_cases; +pub mod utils; diff --git a/api/src/application/ports/git_pull_session_repository.rs b/api/src/application/ports/git_pull_session_repository.rs new file mode 100644 index 00000000..1a330926 --- /dev/null +++ b/api/src/application/ports/git_pull_session_repository.rs @@ -0,0 +1,10 @@ +use async_trait::async_trait; +use uuid::Uuid; + +use crate::application::dto::git::GitPullSessionDto; + +#[async_trait] +pub trait GitPullSessionRepository: Send + Sync { + async fn upsert(&self, session: GitPullSessionDto) -> anyhow::Result<()>; + async fn get(&self, workspace_id: Uuid, id: Uuid) -> anyhow::Result>; +} diff --git a/api/src/application/ports/git_workspace.rs b/api/src/application/ports/git_workspace.rs index 29a3ed49..6c3b5ea3 100644 --- a/api/src/application/ports/git_workspace.rs +++ b/api/src/application/ports/git_workspace.rs @@ -3,8 +3,8 @@ use uuid::Uuid; use crate::application::dto::diff::TextDiffResult; use crate::application::dto::git::{ - GitChangeItem, GitCommitInfo, GitRemoteCheckDto, GitSyncOutcome, GitSyncRequestDto, - GitWorkspaceStatus, + GitChangeItem, GitCommitInfo, GitImportOutcome, GitPullRequestDto, GitPullResultDto, + GitRemoteCheckDto, GitSyncOutcome, GitSyncRequestDto, GitWorkspaceStatus, }; use crate::application::ports::git_repository::UserGitCfg; @@ -32,6 +32,31 @@ pub trait GitWorkspacePort: Send + Sync { req: &GitSyncRequestDto, cfg: Option<&UserGitCfg>, ) -> anyhow::Result; + async fn import_repository( + &self, + workspace_id: Uuid, + actor_id: Uuid, + cfg: &UserGitCfg, + ) -> anyhow::Result; + async fn pull( + &self, + workspace_id: Uuid, + actor_id: Uuid, + req: &GitPullRequestDto, + cfg: &UserGitCfg, + ) -> anyhow::Result; + async fn head_commit(&self, workspace_id: Uuid) -> anyhow::Result>>; + async fn remote_head( + &self, + workspace_id: Uuid, + cfg: &UserGitCfg, + ) -> anyhow::Result>>; + async fn has_pending_changes(&self, workspace_id: Uuid) -> anyhow::Result; + async fn drift_since_commit( + &self, + workspace_id: Uuid, + base_commit: &[u8], + ) -> anyhow::Result; async fn check_remote( &self, diff --git a/api/src/application/ports/mod.rs b/api/src/application/ports/mod.rs index 68e44a05..5dbff5b8 100644 --- a/api/src/application/ports/mod.rs +++ b/api/src/application/ports/mod.rs @@ -6,6 +6,7 @@ pub mod document_exporter; pub mod document_repository; pub mod document_snapshot_archive_repository; pub mod files_repository; +pub mod git_pull_session_repository; pub mod git_rebuild_job_queue; pub mod git_repository; pub mod git_storage; diff --git a/api/src/application/services/api_tokens.rs b/api/src/application/services/api_tokens.rs index 3e89db1b..f6c6a8ec 100644 --- a/api/src/application/services/api_tokens.rs +++ b/api/src/application/services/api_tokens.rs @@ -5,7 +5,6 @@ use argon2::{ password_hash::{PasswordHash, PasswordHasher, PasswordVerifier, SaltString}, }; use rand::{Rng, distributions::Alphanumeric, rngs::OsRng}; -use sha2::{Digest, Sha256}; use uuid::Uuid; use crate::application::dto::api_tokens::{ApiTokenDto, CreatedApiTokenDto}; @@ -14,6 +13,7 @@ use crate::application::services::errors::ServiceError; use crate::application::use_cases::api_tokens::create_token::CreateApiToken; use crate::application::use_cases::api_tokens::list_tokens::ListApiTokens; use crate::application::use_cases::api_tokens::revoke_token::RevokeApiToken; +use crate::application::utils::hash::sha256_hex_str; use crate::domain::workspaces::permissions::{PERM_API_TOKEN_MANAGE, PermissionSet}; pub struct ApiTokenService { @@ -110,9 +110,7 @@ pub fn generate_api_token() -> anyhow::Result { } pub fn compute_digest(token: &str) -> String { - let mut hasher = Sha256::new(); - hasher.update(token.as_bytes()); - hex::encode(hasher.finalize()) + sha256_hex_str(token) } pub fn verify_token(token: &str, token_hash: &str) -> anyhow::Result { diff --git a/api/src/application/services/documents.rs b/api/src/application/services/documents.rs index 71c53eef..1df89a05 100644 --- a/api/src/application/services/documents.rs +++ b/api/src/application/services/documents.rs @@ -1,8 +1,6 @@ -use std::fmt::Write; use std::path::Path; use std::sync::Arc; -use sha2::{Digest, Sha256}; use sqlx::{Pool, Postgres, Transaction}; use tracing::{error, warn}; use uuid::Uuid; @@ -50,6 +48,7 @@ use crate::application::use_cases::documents::snapshot_download::{ }; use crate::application::use_cases::documents::unarchive_document::UnarchiveDocument; use crate::application::use_cases::documents::update_document::UpdateDocument; +use crate::application::utils::hash::sha256_hex; use crate::domain::documents::document::{ BacklinkInfo as DomainBacklink, Document as DomainDocument, OutgoingLink as DomainOutgoingLink, SearchHit, @@ -1406,14 +1405,7 @@ fn duplicate_title(source_title: &str, override_title: Option) -> String } fn hash_bytes(bytes: &[u8]) -> String { - let mut hasher = Sha256::new(); - hasher.update(bytes); - let digest = hasher.finalize(); - let mut out = String::with_capacity(64); - for byte in digest { - let _ = write!(&mut out, "{:02x}", byte); - } - out + sha256_hex(bytes) } fn path_depth(path: &str) -> usize { diff --git a/api/src/application/services/git.rs b/api/src/application/services/git.rs index 3a2b75a1..22e523ad 100644 --- a/api/src/application/services/git.rs +++ b/api/src/application/services/git.rs @@ -4,11 +4,13 @@ use uuid::Uuid; use crate::application::dto::diff::TextDiffResult; use crate::application::dto::git::{ - GitChangeItem, GitCommitInfo, GitConfigDto, GitRemoteCheckDto, GitStatusDto, GitSyncRequestDto, - GitSyncResponseDto, GitignoreUpdateDto, UpsertGitConfigInput, + GitChangeItem, GitCommitInfo, GitConfigDto, GitPullConflictItemDto, GitPullRequestDto, + GitPullResolutionDto, GitPullResultDto, GitPullSessionDto, GitRemoteCheckDto, GitStatusDto, + GitSyncRequestDto, GitSyncResponseDto, GitignoreUpdateDto, UpsertGitConfigInput, }; use crate::application::ports::document_repository::DocumentRepository; use crate::application::ports::files_repository::FilesRepository; +use crate::application::ports::git_pull_session_repository::GitPullSessionRepository; use crate::application::ports::git_repository::GitRepository; use crate::application::ports::git_workspace::GitWorkspacePort; use crate::application::ports::gitignore_port::GitignorePort; @@ -27,8 +29,10 @@ use crate::application::use_cases::git::gitignore_patterns::{ use crate::application::use_cases::git::ignore_document::IgnoreDocument; use crate::application::use_cases::git::ignore_folder::IgnoreFolder; use crate::application::use_cases::git::init_repo::{DeinitRepo, InitRepo}; +use crate::application::use_cases::git::pull::PullRepository; use crate::application::use_cases::git::sync_now::SyncNow; use crate::application::use_cases::git::upsert_config::UpsertGitConfig; +use tracing::warn; pub struct GitService { repo: Arc, @@ -37,6 +41,12 @@ pub struct GitService { docs: Arc, gitignore: Arc, workspace: Arc, + pull_sessions: Arc, +} + +pub struct FinalizePullSessionResult { + pub session: GitPullSessionDto, + pub git_status: Option, } impl GitService { @@ -48,6 +58,7 @@ impl GitService { docs: Arc, gitignore: Arc, workspace: Arc, + pull_sessions: Arc, ) -> Self { Self { repo, @@ -56,6 +67,7 @@ impl GitService { docs, gitignore, workspace, + pull_sessions, } } @@ -143,6 +155,16 @@ impl GitService { || msg_lower.contains("status code: 404") { ServiceError::BadRequest("git_repo_not_found") + } else if msg_lower.contains("notfastforward") + || msg_lower.contains("not fast forward") + || msg_lower.contains("non-fast-forward") + || msg_lower.contains("non fast forward") + || msg_lower.contains("cannot push because a reference") + || msg_lower.contains("failed to push some refs") + || msg_lower.contains("updates were rejected") + || msg_lower.contains("rejected") + { + ServiceError::Conflict } else { ServiceError::from(err) } @@ -223,6 +245,41 @@ impl GitService { .map_err(ServiceError::from) } + pub async fn import_repository( + &self, + workspace_id: Uuid, + actor_id: Uuid, + input: &UpsertGitConfigInput, + ) -> Result { + // Save configuration first + let _ = self.upsert_config(workspace_id, input).await?; + let cfg = self + .repo + .load_user_git_cfg(workspace_id) + .await + .map_err(ServiceError::from)? + .ok_or(ServiceError::BadRequest("git_not_configured"))?; + + self.workspace + .ensure_repository(workspace_id, &cfg.branch_name) + .await + .map_err(ServiceError::from)?; + + self.workspace + .import_repository(workspace_id, actor_id, &cfg) + .await + .map_err(|err| { + let msg = err.to_string().to_lowercase(); + if msg.contains("git_http_auth_redirect") || msg.contains("too many redirects") { + ServiceError::BadRequest("git_auth_redirect") + } else if msg.contains("git_http_not_found") || msg.contains("status code: 404") { + ServiceError::BadRequest("git_repo_not_found") + } else { + ServiceError::from(err) + } + }) + } + pub async fn ignore_document( &self, workspace_id: Uuid, @@ -305,4 +362,378 @@ impl GitService { .await .map_err(ServiceError::from) } + + pub async fn pull_repository( + &self, + workspace_id: Uuid, + actor_id: Uuid, + req: GitPullRequestDto, + ) -> Result { + let uc = PullRepository { + workspace: self.workspace.as_ref(), + repo: self.repo.as_ref(), + }; + let mut dto = uc + .execute(workspace_id, actor_id, req) + .await + .map_err(|err| { + let msg = err.to_string(); + if msg.contains("pending changes") { + ServiceError::BadRequest("workspace_has_pending_changes") + } else if msg.contains("not initialized") { + ServiceError::BadRequest("repository_not_initialized") + } else if msg.contains("remote not configured") { + ServiceError::BadRequest("remote_not_configured") + } else if msg.contains("git_not_configured") { + ServiceError::BadRequest("remote_not_configured") + } else if msg.contains("custom_text content required") { + ServiceError::BadRequest("resolution_content_required") + } else { + ServiceError::from(err) + } + })?; + + if let Some(conflicts) = dto.conflicts.take() { + dto.conflicts = Some( + self.attach_conflict_documents(workspace_id, conflicts) + .await?, + ); + } + + Ok(dto) + } + + pub async fn start_pull_session_flow( + &self, + workspace_id: Uuid, + actor_id: Uuid, + ) -> Result { + let mut dto = self + .pull_repository( + workspace_id, + actor_id, + GitPullRequestDto { + resolutions: Vec::new(), + }, + ) + .await?; + let conflicts = dto.conflicts.clone().unwrap_or_default(); + let session_id = Uuid::new_v4(); + // Align recorded base commit with the current head so stale detection does not flag a + // successfully merged session. + if let Some(head) = self.workspace.head_commit(workspace_id).await? { + dto.base_commit = Some(head); + } + let status = if !dto.success && conflicts.is_empty() { + "error".to_string() + } else if conflicts.is_empty() { + "merged".to_string() + } else { + "pending".to_string() + }; + let session = GitPullSessionDto { + id: session_id, + workspace_id, + status, + conflicts, + resolutions: Vec::new(), + message: Some(dto.message.clone()), + base_commit: dto.base_commit, + remote_commit: dto.remote_commit, + }; + self.save_pull_session(session.clone()).await?; + Ok(session) + } + + pub async fn resolve_pull_session_flow( + &self, + workspace_id: Uuid, + actor_id: Uuid, + session_id: Uuid, + resolutions: Vec, + ) -> Result { + let existing = self + .load_pull_session(workspace_id, session_id) + .await? + .ok_or(ServiceError::NotFound)?; + if self.pull_session_is_stale(workspace_id, &existing).await? { + let mut stale = existing.clone(); + stale.status = "stale".to_string(); + stale.message = Some("Pull session is stale".to_string()); + let _ = self.save_pull_session(stale.clone()).await; + return Ok(stale); + } + + let dto = self + .pull_repository( + workspace_id, + actor_id, + GitPullRequestDto { + resolutions: resolutions.clone(), + }, + ) + .await?; + let conflicts = dto.conflicts.clone().unwrap_or_default(); + let status = if !dto.success && conflicts.is_empty() { + "error".to_string() + } else if conflicts.is_empty() { + "merged".to_string() + } else { + "resolving".to_string() + }; + // When the pull completed (no conflicts), record the latest head as the session base so + // subsequent finalize calls don't treat the session as stale. + let mut base_commit = dto.base_commit.clone(); + if conflicts.is_empty() { + if let Some(head) = self.workspace.head_commit(workspace_id).await? { + base_commit = Some(head); + } + } + let session = GitPullSessionDto { + id: session_id, + workspace_id, + status, + conflicts, + resolutions, + message: Some(dto.message.clone()), + base_commit, + remote_commit: dto.remote_commit, + }; + self.save_pull_session(session.clone()).await?; + Ok(session) + } + + pub async fn finalize_pull_session_flow( + &self, + workspace_id: Uuid, + session_id: Uuid, + ) -> Result { + let existing = self + .load_pull_session(workspace_id, session_id) + .await? + .ok_or(ServiceError::NotFound)?; + if existing.status == "merged" { + let git_status = self.get_status(workspace_id).await?; + return Ok(FinalizePullSessionResult { + session: existing, + git_status: Some(git_status), + }); + } + if existing.status == "stale" { + let mut stale = existing.clone(); + if stale.message.is_none() { + stale.message = Some("Pull session is stale".to_string()); + let _ = self.save_pull_session(stale.clone()).await; + } + return Ok(FinalizePullSessionResult { + session: stale, + git_status: None, + }); + } + if existing.status == "error" { + return Ok(FinalizePullSessionResult { + session: existing, + git_status: None, + }); + } + if matches!(existing.status.as_str(), "pending" | "resolving") + && self + .pull_session_is_stale(workspace_id, &existing) + .await? + { + let mut stale = existing.clone(); + stale.status = "stale".to_string(); + if stale.message.is_none() { + stale.message = Some("Pull session is stale".to_string()); + } + let _ = self.save_pull_session(stale.clone()).await; + return Ok(FinalizePullSessionResult { + session: stale, + git_status: None, + }); + } + if !existing.conflicts.is_empty() { + return Ok(FinalizePullSessionResult { + session: existing, + git_status: None, + }); + } + let git_status = self.get_status(workspace_id).await?; + let merged = GitPullSessionDto { + id: session_id, + workspace_id, + status: "merged".to_string(), + conflicts: Vec::new(), + resolutions: existing.resolutions.clone(), + message: Some("merge completed".to_string()), + base_commit: existing.base_commit.clone(), + remote_commit: existing.remote_commit.clone(), + }; + let _ = self.save_pull_session(merged.clone()).await; + Ok(FinalizePullSessionResult { + session: merged, + git_status: Some(git_status), + }) + } + + pub async fn load_pull_session_with_stale_check( + &self, + workspace_id: Uuid, + id: Uuid, + ) -> Result, ServiceError> { + let mut session = match self.load_pull_session(workspace_id, id).await? { + Some(s) => s, + None => return Ok(None), + }; + if matches!(session.status.as_str(), "pending" | "resolving") + && self + .pull_session_is_stale(workspace_id, &session) + .await? + { + session.status = "stale".to_string(); + session.message = Some("Pull session is stale".to_string()); + let _ = self.save_pull_session(session.clone()).await; + } + Ok(Some(session)) + } + + async fn attach_conflict_documents( + &self, + workspace_id: Uuid, + conflicts: Vec, + ) -> Result, ServiceError> { + let mut out = Vec::with_capacity(conflicts.len()); + let docs = self + .docs + .list_workspace_documents(workspace_id) + .await + .map_err(ServiceError::from)?; + + let normalize = |path: &str| { + path.trim_start_matches("./") + .trim_start_matches('/') + .to_string() + }; + + for mut conflict in conflicts { + if conflict.document_id.is_some() { + out.push(conflict); + continue; + } + let candidate = normalize(&conflict.path); + + let mut matched = None; + for doc in docs.iter() { + let mut paths: Vec = Vec::new(); + if let Some(p) = doc.path.as_ref() { + let norm = normalize(p); + if !norm.is_empty() { + paths.push(norm); + } + } + let desired = normalize(&doc.desired_path); + if !desired.is_empty() { + paths.push(desired); + } + + if paths.iter().any(|p| { + candidate == *p + || candidate.ends_with(&format!("/{p}")) + || p.ends_with(&candidate) + }) { + matched = Some(doc.id); + break; + } + } + + conflict.document_id = matched; + if let Some(doc_id) = matched { + if let Some(doc) = docs.iter().find(|d| d.id == doc_id) { + conflict.path = doc.desired_path.clone(); + } + } + out.push(conflict); + } + + Ok(out) + } + + pub async fn save_pull_session(&self, session: GitPullSessionDto) -> Result<(), ServiceError> { + self.pull_sessions + .upsert(session) + .await + .map_err(ServiceError::from) + } + + pub async fn load_pull_session( + &self, + workspace_id: Uuid, + id: Uuid, + ) -> Result, ServiceError> { + self.pull_sessions + .get(workspace_id, id) + .await + .map_err(ServiceError::from) + } + + pub async fn pull_session_is_stale( + &self, + workspace_id: Uuid, + session: &GitPullSessionDto, + ) -> Result { + let cfg = self + .repo + .load_user_git_cfg(workspace_id) + .await + .map_err(ServiceError::from)?; + let Some(cfg) = cfg else { + return Err(ServiceError::BadRequest("remote_not_configured")); + }; + + if let Some(saved_base) = session.base_commit.as_ref() { + let current_head = self + .workspace + .head_commit(workspace_id) + .await + .map_err(ServiceError::from)?; + match current_head { + Some(head) if saved_base == &head => {} + Some(head) + if session + .remote_commit + .as_ref() + .is_some_and(|remote| remote == &head) => {} + Some(_) | None => return Ok(true), + } + } + + if let Some(saved_remote) = session.remote_commit.as_ref() { + match self.workspace.remote_head(workspace_id, &cfg).await { + Ok(Some(current_remote)) => { + if saved_remote != ¤t_remote { + return Ok(true); + } + } + Ok(None) => return Ok(true), + Err(err) => { + let msg = err.to_string(); + let mapped = if msg.contains("not initialized") { + ServiceError::BadRequest("repository_not_initialized") + } else if msg.contains("remote not configured") { + ServiceError::BadRequest("remote_not_configured") + } else { + ServiceError::from(err) + }; + warn!( + workspace_id = %workspace_id, + error = %msg, + "git_pull_remote_head_unavailable" + ); + return Err(mapped); + } + } + } + + Ok(false) + } } diff --git a/api/src/application/services/git_rebuild.rs b/api/src/application/services/git_rebuild.rs index 6b71b6d3..6d4993c7 100644 --- a/api/src/application/services/git_rebuild.rs +++ b/api/src/application/services/git_rebuild.rs @@ -341,6 +341,39 @@ mod tests { } } + async fn import_repository( + &self, + _workspace_id: Uuid, + _actor_id: Uuid, + _cfg: &crate::application::ports::git_repository::UserGitCfg, + ) -> anyhow::Result { + Ok(crate::application::dto::git::GitImportOutcome { + files_changed: 0, + commit_hash: None, + docs_created: 0, + attachments_created: 0, + message: "not implemented".to_string(), + }) + } + + async fn pull( + &self, + _workspace_id: Uuid, + _actor_id: Uuid, + _req: &crate::application::dto::git::GitPullRequestDto, + _cfg: &crate::application::ports::git_repository::UserGitCfg, + ) -> anyhow::Result { + Ok(crate::application::dto::git::GitPullResultDto { + success: true, + message: "ok".to_string(), + files_changed: 0, + commit_hash: None, + conflicts: None, + base_commit: None, + remote_commit: None, + }) + } + async fn check_remote( &self, _workspace_id: Uuid, @@ -352,6 +385,30 @@ mod tests { reason: None, }) } + + async fn head_commit(&self, _workspace_id: Uuid) -> anyhow::Result>> { + Ok(None) + } + + async fn remote_head( + &self, + _workspace_id: Uuid, + _cfg: &crate::application::ports::git_repository::UserGitCfg, + ) -> anyhow::Result>> { + Ok(None) + } + + async fn has_pending_changes(&self, _workspace_id: Uuid) -> anyhow::Result { + Ok(false) + } + + async fn drift_since_commit( + &self, + _workspace_id: Uuid, + _base_commit: &[u8], + ) -> anyhow::Result { + Ok(false) + } } struct RecordingJobQueue { diff --git a/api/src/application/services/markdown/mod.rs b/api/src/application/services/markdown/mod.rs index 3d748018..87f2a3e7 100644 --- a/api/src/application/services/markdown/mod.rs +++ b/api/src/application/services/markdown/mod.rs @@ -3,6 +3,8 @@ use serde::{Deserialize, Serialize}; use std::collections::HashSet; use std::sync::Mutex; +use crate::application::utils::hash::sha256_hex; + #[derive(Debug, Deserialize, Serialize, Default, Clone)] #[serde(default)] pub struct RenderOptions { @@ -51,14 +53,6 @@ fn wants_feature(opts: &RenderOptions, name: &str) -> bool { } } -fn sha256_hex(s: &str) -> String { - use sha2::{Digest, Sha256}; - let mut hasher = Sha256::new(); - hasher.update(s.as_bytes()); - let out = hasher.finalize(); - format!("{:x}", out) -} - fn normalize_wikilink_label(raw: &str) -> (String, bool) { let mut label = raw.trim().to_string(); if label.is_empty() { diff --git a/api/src/application/services/realtime/snapshot.rs b/api/src/application/services/realtime/snapshot.rs index 920d8ca9..7a5db483 100644 --- a/api/src/application/services/realtime/snapshot.rs +++ b/api/src/application/services/realtime/snapshot.rs @@ -1,7 +1,6 @@ use std::sync::Arc; use async_trait::async_trait; -use sha2::{Digest, Sha256}; use tracing::warn; use uuid::Uuid; use yrs::updates::decoder::Decode; @@ -19,6 +18,7 @@ use crate::application::ports::storage_projection_queue::{ }; use crate::application::ports::tagging_repository::TaggingRepository; use crate::application::services::tagging; +use crate::application::utils::hash::sha256_hex; pub struct SnapshotService { state_reader: Arc, @@ -352,13 +352,6 @@ fn render_markdown_bytes(doc_id: &Uuid, title: &str, contents: &str) -> Vec formatted.into_bytes() } -fn sha256_hex(data: &[u8]) -> String { - let mut hasher = Sha256::new(); - hasher.update(data); - let digest = hasher.finalize(); - hex::encode(digest) -} - fn apply_update_bytes(doc: &Doc, bytes: &[u8]) -> anyhow::Result<()> { let update = Update::decode_v1(bytes)?; let mut txn = doc.transact_mut(); diff --git a/api/src/application/services/storage_ingest.rs b/api/src/application/services/storage_ingest.rs index c10b8121..fbb8c62b 100644 --- a/api/src/application/services/storage_ingest.rs +++ b/api/src/application/services/storage_ingest.rs @@ -5,7 +5,6 @@ use std::sync::Arc; use async_trait::async_trait; use serde::Deserialize; use serde_json::{Value, json}; -use sha2::{Digest, Sha256}; use tracing::{debug, info, warn}; use uuid::Uuid; @@ -22,6 +21,7 @@ use crate::application::services::storage_projection_cache::RecentProjectionCach use crate::application::services::workspaces::{ WorkspacePermissionResolver, permission_snapshot::permission_set_from_snapshot, }; +use crate::application::utils::hash::sha256_hex; use crate::domain::documents::document::Document as DomainDocument; use crate::domain::workspaces::permissions::PermissionSet; @@ -253,13 +253,7 @@ impl StorageIngestService { Err(err) => return Err(err), }; let size = bytes.len() as i64; - let mut hasher = Sha256::new(); - hasher.update(&bytes); - let digest = hasher.finalize(); - let hash = digest - .iter() - .map(|b| format!("{b:02x}")) - .collect::(); + let hash = sha256_hex(&bytes); self.files_repo .update_hash_and_size(file_id, size, &hash) .await?; @@ -726,7 +720,8 @@ struct MarkdownFrontMatter { fn parse_markdown_payload(bytes: Vec) -> anyhow::Result { let content_hash = sha256_hex(&bytes); - let text = String::from_utf8(bytes)?; + // Accept lossy UTF-8 to avoid retry storms on malformed files; non-UTF8 bytes become U+FFFD. + let text = String::from_utf8_lossy(&bytes).to_string(); let trimmed = text.trim_start_matches('\u{feff}'); if let Some((front, body)) = split_front_matter(trimmed) { if let Ok(front_matter) = serde_yaml::from_str::(front) { @@ -791,13 +786,6 @@ fn find_front_matter_end(s: &str) -> Option<(usize, usize)> { None } -fn sha256_hex(bytes: &[u8]) -> String { - let mut hasher = Sha256::new(); - hasher.update(bytes); - let digest = hasher.finalize(); - digest.iter().map(|b| format!("{b:02x}")).collect() -} - fn is_not_found_error(err: &anyhow::Error) -> bool { err.chain().any(|cause| { cause diff --git a/api/src/application/use_cases/git/helpers.rs b/api/src/application/use_cases/git/helpers.rs index 83d39ed4..0b4f7495 100644 --- a/api/src/application/use_cases/git/helpers.rs +++ b/api/src/application/use_cases/git/helpers.rs @@ -82,6 +82,5 @@ pub fn needs_force_retry(err: &Error) -> bool { || msg.contains("remote repository already contains commit") || msg.contains("non-fast-forward") || msg.contains("non fast forward") - || msg.contains("failed to push some refs") - || msg.contains("rejected") + || (msg.contains("push") && msg.contains("rejected")) } diff --git a/api/src/application/use_cases/git/mod.rs b/api/src/application/use_cases/git/mod.rs index d70f46d1..01832bc2 100644 --- a/api/src/application/use_cases/git/mod.rs +++ b/api/src/application/use_cases/git/mod.rs @@ -10,5 +10,6 @@ pub mod helpers; pub mod ignore_document; pub mod ignore_folder; pub mod init_repo; +pub mod pull; pub mod sync_now; pub mod upsert_config; diff --git a/api/src/application/use_cases/git/pull.rs b/api/src/application/use_cases/git/pull.rs new file mode 100644 index 00000000..f1d8d801 --- /dev/null +++ b/api/src/application/use_cases/git/pull.rs @@ -0,0 +1,34 @@ +use anyhow::anyhow; +use uuid::Uuid; + +use crate::application::dto::git::{GitPullRequestDto, GitPullResultDto}; +use crate::application::ports::git_repository::GitRepository; +use crate::application::ports::git_workspace::GitWorkspacePort; + +pub struct PullRepository<'a, R, W> +where + R: GitRepository + ?Sized, + W: GitWorkspacePort + ?Sized, +{ + pub workspace: &'a W, + pub repo: &'a R, +} + +impl<'a, R, W> PullRepository<'a, R, W> +where + R: GitRepository + ?Sized, + W: GitWorkspacePort + ?Sized, +{ + pub async fn execute( + &self, + workspace_id: Uuid, + actor_id: Uuid, + req: GitPullRequestDto, + ) -> anyhow::Result { + let cfg = self.repo.load_user_git_cfg(workspace_id).await?; + let cfg = cfg.ok_or_else(|| anyhow!("git_not_configured"))?; + self.workspace + .pull(workspace_id, actor_id, &req, &cfg) + .await + } +} diff --git a/api/src/application/use_cases/git/sync_now.rs b/api/src/application/use_cases/git/sync_now.rs index 1e4cf7b5..c10d8097 100644 --- a/api/src/application/use_cases/git/sync_now.rs +++ b/api/src/application/use_cases/git/sync_now.rs @@ -1,10 +1,8 @@ -use tracing::warn; use uuid::Uuid; use crate::application::dto::git::{GitSyncRequestDto, GitSyncResponseDto}; use crate::application::ports::git_repository::GitRepository; use crate::application::ports::git_workspace::GitWorkspacePort; -use crate::application::use_cases::git::helpers::needs_force_retry; pub struct SyncNow<'a, R, W> where @@ -26,25 +24,11 @@ where req: GitSyncRequestDto, ) -> anyhow::Result { let cfg = self.repo.load_user_git_cfg(workspace_id).await?; - let mut attempt_req = req.clone(); - let outcome = match self + let attempt_req = req.clone(); + let outcome = self .workspace .sync(workspace_id, &attempt_req, cfg.as_ref()) - .await - { - Ok(outcome) => outcome, - Err(err) => { - if !attempt_req.force.unwrap_or(false) && needs_force_retry(&err) { - warn!(workspace_id = %workspace_id, "git_sync_retrying_with_force"); - attempt_req.force = Some(true); - self.workspace - .sync(workspace_id, &attempt_req, cfg.as_ref()) - .await? - } else { - return Err(err); - } - } - }; + .await?; if let Some(cfg) = cfg.as_ref() { if !cfg.repository_url.is_empty() { @@ -60,7 +44,12 @@ where ) .await; } else { - let status = if outcome.pushed { "success" } else { "error" }; + // Treat "nothing to commit" as success even if no push occurred. + let status = if outcome.files_changed == 0 || outcome.pushed { + "success" + } else { + "error" + }; let _ = self .repo .log_sync_operation( diff --git a/api/src/application/utils/hash.rs b/api/src/application/utils/hash.rs new file mode 100644 index 00000000..a2068704 --- /dev/null +++ b/api/src/application/utils/hash.rs @@ -0,0 +1,13 @@ +use sha2::{Digest, Sha256}; + +/// Return lowercase hex SHA-256 for arbitrary bytes. +pub fn sha256_hex>(input: T) -> String { + let mut hasher = Sha256::new(); + hasher.update(input.as_ref()); + hex::encode(hasher.finalize()) +} + +/// Convenience helper for string inputs. +pub fn sha256_hex_str(input: &str) -> String { + sha256_hex(input.as_bytes()) +} diff --git a/api/src/application/utils/mod.rs b/api/src/application/utils/mod.rs new file mode 100644 index 00000000..ec5d33c1 --- /dev/null +++ b/api/src/application/utils/mod.rs @@ -0,0 +1 @@ +pub mod hash; diff --git a/api/src/bin/export-openapi.rs b/api/src/bin/export-openapi.rs index 7eb19e90..581716f4 100644 --- a/api/src/bin/export-openapi.rs +++ b/api/src/bin/export-openapi.rs @@ -76,6 +76,12 @@ use utoipa::OpenApi; git::get_working_diff, git::get_commit_diff, git::sync_now, + git::import_repository, + git::pull_repository, + git::start_pull_session, + git::get_pull_session, + git::resolve_pull_session, + git::finalize_pull_session, git::init_repository, git::deinit_repository, git::ignore_document, @@ -181,6 +187,12 @@ use utoipa::OpenApi; git::GitStatus, git::GitSyncRequest, git::GitSyncResponse, + git::GitPullRequest, + git::GitPullResponse, + git::GitImportResponse, + git::GitPullSessionResponse, + git::GitPullResolution, + git::GitPullConflictItem, git::GitChangeItem, git::GitChangesResponse, git::GitCommitItem, diff --git a/api/src/bin/refmd.rs b/api/src/bin/refmd.rs index 9a05bec2..cbe3ad2e 100644 --- a/api/src/bin/refmd.rs +++ b/api/src/bin/refmd.rs @@ -592,6 +592,64 @@ impl GitWorkspacePort for CliGitWorkspace { bail!("sync not supported in refmd CLI"); } + async fn pull( + &self, + _workspace_id: Uuid, + _actor_id: Uuid, + _req: &api::application::dto::git::GitPullRequestDto, + _cfg: &api::application::ports::git_repository::UserGitCfg, + ) -> anyhow::Result { + bail!("pull not supported in refmd CLI"); + } + + async fn import_repository( + &self, + _workspace_id: Uuid, + _actor_id: Uuid, + _cfg: &api::application::ports::git_repository::UserGitCfg, + ) -> anyhow::Result { + bail!("import not supported in refmd CLI"); + } + + async fn head_commit(&self, workspace_id: Uuid) -> anyhow::Result>> { + Ok(self + .latest_commit_meta(workspace_id) + .await? + .map(|m| m.commit_id)) + } + + async fn remote_head( + &self, + _workspace_id: Uuid, + _cfg: &api::application::ports::git_repository::UserGitCfg, + ) -> anyhow::Result>> { + Ok(None) + } + + async fn has_pending_changes(&self, workspace_id: Uuid) -> anyhow::Result { + let dirty_rows = self.fetch_dirty(workspace_id).await?; + Ok(!dirty_rows.is_empty()) + } + + async fn drift_since_commit( + &self, + workspace_id: Uuid, + base_commit: &[u8], + ) -> anyhow::Result { + // CLI helper: fallback to dirty check when full state comparison is not available. + if self.has_pending_changes(workspace_id).await? { + return Ok(true); + } + // If the base commit is not the latest, consider it stale. + let latest = self.latest_commit_meta(workspace_id).await?; + if let Some(meta) = latest { + if meta.commit_id.as_slice() != base_commit { + return Ok(true); + } + } + Ok(false) + } + async fn check_remote( &self, _workspace_id: Uuid, diff --git a/api/src/infrastructure/db/repositories/git_pull_session_repository_sqlx.rs b/api/src/infrastructure/db/repositories/git_pull_session_repository_sqlx.rs new file mode 100644 index 00000000..3aa4339e --- /dev/null +++ b/api/src/infrastructure/db/repositories/git_pull_session_repository_sqlx.rs @@ -0,0 +1,89 @@ +use async_trait::async_trait; +use sqlx::types::Json; +use sqlx::{PgPool, Row}; +use uuid::Uuid; + +use crate::application::dto::git::{ + GitPullConflictItemDto, GitPullResolutionDto, GitPullSessionDto, +}; +use crate::application::ports::git_pull_session_repository::GitPullSessionRepository; + +pub struct GitPullSessionRepositorySqlx { + pool: PgPool, +} + +impl GitPullSessionRepositorySqlx { + pub fn new(pool: PgPool) -> Self { + Self { pool } + } +} + +#[async_trait] +impl GitPullSessionRepository for GitPullSessionRepositorySqlx { + async fn upsert(&self, session: GitPullSessionDto) -> anyhow::Result<()> { + let GitPullSessionDto { + id, + workspace_id, + status, + conflicts, + resolutions, + message, + base_commit, + remote_commit, + } = session; + sqlx::query( + r#"INSERT INTO git_pull_sessions (id, workspace_id, status, conflicts, resolutions, created_at, updated_at, message, base_commit, remote_commit) + VALUES ($1, $2, $3, $4, $5, now(), now(), $6, $7, $8) + ON CONFLICT (id) DO UPDATE SET + status = EXCLUDED.status, + conflicts = EXCLUDED.conflicts, + resolutions = EXCLUDED.resolutions, + message = EXCLUDED.message, + base_commit = EXCLUDED.base_commit, + remote_commit = EXCLUDED.remote_commit, + updated_at = now()"#, + ) + .bind(id) + .bind(workspace_id) + .bind(status) + .bind(Json(conflicts)) + .bind(Json(resolutions)) + .bind(message.clone()) + .bind(base_commit.clone()) + .bind(remote_commit.clone()) + .execute(&self.pool) + .await?; + Ok(()) + } + + async fn get(&self, workspace_id: Uuid, id: Uuid) -> anyhow::Result> { + let row = sqlx::query( + r#"SELECT id, workspace_id, status, conflicts, resolutions, message, base_commit, remote_commit FROM git_pull_sessions + WHERE id = $1 AND workspace_id = $2"#, + ) + .bind(id) + .bind(workspace_id) + .fetch_optional(&self.pool) + .await?; + + let Some(row) = row else { + return Ok(None); + }; + let conflicts: Vec = row + .get::>, _>("conflicts") + .0; + let resolutions: Vec = row + .get::>, _>("resolutions") + .0; + Ok(Some(GitPullSessionDto { + id, + workspace_id, + status: row.get::("status"), + conflicts, + resolutions, + message: row.try_get::, _>("message").unwrap_or(None), + base_commit: row.get::>, _>("base_commit"), + remote_commit: row.get::>, _>("remote_commit"), + })) + } +} diff --git a/api/src/infrastructure/db/repositories/mod.rs b/api/src/infrastructure/db/repositories/mod.rs index 72ac2c11..2841954e 100644 --- a/api/src/infrastructure/db/repositories/mod.rs +++ b/api/src/infrastructure/db/repositories/mod.rs @@ -3,6 +3,7 @@ pub mod api_token_repository_sqlx; pub mod document_repository_sqlx; pub mod document_snapshot_archive_repository_sqlx; pub mod files_repository_sqlx; +pub mod git_pull_session_repository_sqlx; pub mod git_repository_sqlx; pub mod linkgraph_repository_sqlx; pub mod plugin_installation_repository_sqlx; diff --git a/api/src/infrastructure/documents/git_dirty_subscriber.rs b/api/src/infrastructure/documents/git_dirty_subscriber.rs index 6eb218f5..29165be3 100644 --- a/api/src/infrastructure/documents/git_dirty_subscriber.rs +++ b/api/src/infrastructure/documents/git_dirty_subscriber.rs @@ -27,6 +27,16 @@ impl GitDirtyDocEventSubscriber { .map(|row| row.flatten()) } + #[allow(dead_code)] + async fn desired_path(&self, doc_id: Uuid) -> anyhow::Result> { + sqlx::query_scalar::<_, Option>("SELECT desired_path FROM documents WHERE id = $1") + .bind(doc_id) + .fetch_optional(&self.pool) + .await + .map_err(anyhow::Error::from) + .map(|row| row.flatten()) + } + async fn doc_type(&self, doc_id: Uuid) -> anyhow::Result> { sqlx::query_scalar::<_, Option>("SELECT type FROM documents WHERE id = $1") .bind(doc_id) diff --git a/api/src/infrastructure/git/workspace.rs b/api/src/infrastructure/git/workspace.rs index ee3f493c..62def6e8 100644 --- a/api/src/infrastructure/git/workspace.rs +++ b/api/src/infrastructure/git/workspace.rs @@ -1,7 +1,7 @@ use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet}; use std::fs; use std::io::{self, ErrorKind, Write}; -use std::path::PathBuf; +use std::path::{Path, PathBuf}; use std::sync::Arc; use anyhow::{Context, anyhow}; @@ -19,24 +19,30 @@ use uuid::Uuid; use crate::application::dto::diff::TextDiffResult; use crate::application::dto::git::{ - GitChangeItem, GitCommitInfo, GitRemoteCheckDto, GitSyncOutcome, GitSyncRequestDto, - GitWorkspaceStatus, + GitChangeItem, GitCommitInfo, GitImportOutcome, GitPullConflictItemDto, GitPullRequestDto, + GitPullResultDto, GitRemoteCheckDto, GitSyncOutcome, GitSyncRequestDto, GitWorkspaceStatus, }; +use crate::application::ports::document_repository::DocumentRepository; use crate::application::ports::git_repository::UserGitCfg; use crate::application::ports::git_storage::{ BlobKey, CommitMeta, GitStorage, decode_commit_id, encode_commit_id, }; use crate::application::ports::git_workspace::GitWorkspacePort; +use crate::application::ports::realtime_port::RealtimeEngine; use crate::application::ports::storage_port::StorageResolverPort; use crate::application::services::diff::text_diff::compute_text_diff; -use crate::application::services::realtime::snapshot::SnapshotService; +use crate::application::services::realtime::snapshot::{SnapshotService, snapshot_from_markdown}; +use crate::application::utils::hash::sha256_hex; use crate::infrastructure::db::PgPool; +use tokio::fs as async_fs; pub struct GitWorkspaceService { pool: PgPool, git_storage: Arc, storage: Arc, snapshot: Arc, + realtime: Arc, + docs: Arc, } impl GitWorkspaceService { @@ -45,15 +51,67 @@ impl GitWorkspaceService { git_storage: Arc, storage: Arc, snapshot: Arc, + realtime: Arc, + docs: Arc, ) -> anyhow::Result { Ok(Self { pool, git_storage, storage, snapshot, + realtime, + docs, }) } + fn is_missing_objects(err: &anyhow::Error) -> bool { + let msg = err.to_string().to_lowercase(); + msg.contains("missing objects") || msg.contains("packfile is missing") + } + + async fn recover_missing_objects( + &self, + workspace_id: Uuid, + cfg: &UserGitCfg, + ) -> anyhow::Result<()> { + // Pick branch from cfg or fallback to repository state default. + let branch = if cfg.branch_name.is_empty() { + self.load_repository_state(workspace_id) + .await? + .map(|(_, default_branch)| default_branch) + .unwrap_or_else(|| "main".to_string()) + } else { + cfg.branch_name.clone() + }; + + let mut tx = self.pool.begin().await?; + sqlx::query("DELETE FROM git_dirty_files WHERE workspace_id = $1") + .bind(workspace_id) + .execute(&mut *tx) + .await?; + sqlx::query("DELETE FROM git_commits WHERE workspace_id = $1") + .bind(workspace_id) + .execute(&mut *tx) + .await?; + sqlx::query( + "UPDATE git_repository_state SET initialized = true, default_branch = $2, updated_at = now() WHERE workspace_id = $1", + ) + .bind(workspace_id) + .bind(&branch) + .execute(&mut *tx) + .await?; + tx.commit().await?; + + let _ = self.git_storage.delete_all(workspace_id).await; + let _ = self.git_storage.set_latest_commit(workspace_id, None).await; + + // Re-bootstrap remote history (best effort). + let _ = self + .bootstrap_remote_history(workspace_id, cfg, branch.as_str()) + .await; + Ok(()) + } + async fn load_repository_state( &self, workspace_id: Uuid, @@ -186,14 +244,24 @@ impl GitWorkspaceService { return Ok(None); } + let pack_bytes_master = read_first_pack(repo.path())?.ok_or_else(|| { + anyhow!( + "remote fetch produced no pack files for workspace {}", + workspace_id + ) + })?; + let mut latest_meta = self.git_storage.latest_commit(workspace_id).await?; for oid in ordered { - if self - .commit_meta_by_id(workspace_id, oid.as_bytes()) - .await? - .is_some() - { + let existing_meta = self.commit_meta_by_id(workspace_id, oid.as_bytes()).await?; + let existing_pack = self + .git_storage + .fetch_pack_for_commit(workspace_id, oid.as_bytes()) + .await?; + // Skip only when both DB row and pack already exist. + if existing_meta.is_some() && existing_pack.is_some() { + latest_meta = existing_meta; continue; } @@ -231,11 +299,9 @@ impl GitWorkspaceService { ); } - let mut pack_builder = repo.packbuilder()?; - pack_builder.insert_commit(oid)?; - let mut pack_buf = git2::Buf::new(); - pack_builder.write_buf(&mut pack_buf)?; - let pack_bytes = pack_buf.to_vec(); + let pack_builder = repo.packbuilder()?; + // Use the full remote pack for every commit to avoid thin-pack corruption. + let pack_bytes = pack_bytes_master.clone(); drop(pack_builder); let commit_id = oid.as_bytes().to_vec(); @@ -301,7 +367,7 @@ impl GitWorkspaceService { } let mut tx = self.pool.begin().await?; - let insert_res = sqlx::query( + let upsert_res = sqlx::query( r#"INSERT INTO git_commits ( commit_id, parent_commit_id, @@ -313,7 +379,14 @@ impl GitWorkspaceService { pack_key, file_hash_index ) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9) - ON CONFLICT (workspace_id, commit_id) DO NOTHING"#, + ON CONFLICT (workspace_id, commit_id) DO UPDATE SET + parent_commit_id = EXCLUDED.parent_commit_id, + message = EXCLUDED.message, + author_name = EXCLUDED.author_name, + author_email = EXCLUDED.author_email, + committed_at = EXCLUDED.committed_at, + pack_key = EXCLUDED.pack_key, + file_hash_index = EXCLUDED.file_hash_index"#, ) .bind(meta.commit_id.clone()) .bind(meta.parent_commit_id.clone()) @@ -327,7 +400,7 @@ impl GitWorkspaceService { .execute(&mut *tx) .await; - if let Err(err) = insert_res { + if let Err(err) = upsert_res { tx.rollback().await.ok(); let _ = self .git_storage @@ -643,24 +716,23 @@ impl GitWorkspaceService { ) -> anyhow::Result> { let mut state: HashMap = HashMap::new(); - let doc_rows = sqlx::query( - "SELECT id, desired_path FROM documents WHERE owner_id = $1 AND type <> 'folder'", - ) - .bind(workspace_id) - .fetch_all(&self.pool) - .await?; + let doc_rows = self + .docs + .list_workspace_documents(workspace_id) + .await? + .into_iter() + .filter(|d| d.doc_type != "folder"); - for row in doc_rows { - let doc_id: Uuid = row.get("id"); - let export = match self.snapshot.export_current_markdown(&doc_id).await? { + for doc in doc_rows { + let export = match self.snapshot.export_current_markdown(&doc.id).await? { Some(export) => export, None => continue, }; let repo_path = export .repo_path - .or_else(|| row.try_get::("desired_path").ok()) + .or_else(|| Some(doc.desired_path.clone())) .map(normalize_repo_path) - .ok_or_else(|| anyhow!("missing_repo_path_for_doc {}", doc_id))?; + .ok_or_else(|| anyhow!("missing_repo_path_for_doc {}", doc.id))?; state.insert( repo_path, FileSnapshot { @@ -805,28 +877,33 @@ impl GitWorkspaceService { } } + // First try by normalized repo path (documents.path). Fall back to desired_path for older records. + let all_docs = self.docs.list_workspace_documents(workspace_id).await?; + for (candidate, archived_only) in candidates { - let row = if archived_only { - sqlx::query( - "SELECT id FROM documents WHERE owner_id = $1 AND desired_path = $2 AND archived_at IS NOT NULL AND type <> 'folder' LIMIT 1", - ) - .bind(workspace_id) - .bind(candidate) - .fetch_optional(&self.pool) - .await? + let lookup_path = format!("{}/{}", workspace_id, candidate); + let from_path = self + .docs + .get_by_owner_and_path(workspace_id, &lookup_path) + .await?; + + let doc = if let Some(doc) = from_path { + Some(doc) } else { - sqlx::query( - "SELECT id FROM documents WHERE owner_id = $1 AND desired_path = $2 AND type <> 'folder' LIMIT 1", - ) - .bind(workspace_id) - .bind(candidate) - .fetch_optional(&self.pool) - .await? + all_docs + .iter() + .find(|d| normalize_repo_path(d.desired_path.clone()) == candidate) + .cloned() }; - if let Some(row) = row { - let doc_id: Uuid = row.get("id"); - if let Some(export) = self.snapshot.export_current_markdown(&doc_id).await? { + if let Some(doc) = doc { + if doc.doc_type == "folder" { + continue; + } + if archived_only && doc.archived_at.is_none() { + continue; + } + if let Some(export) = self.snapshot.export_current_markdown(&doc.id).await? { return Ok(Some((export.bytes, export.content_hash))); } } @@ -920,6 +997,373 @@ impl GitWorkspaceService { } } + #[allow(dead_code)] + async fn state_from_commit_meta( + &self, + workspace_id: Uuid, + meta: &CommitMeta, + ) -> anyhow::Result> { + let mut state: HashMap = HashMap::new(); + for path in meta.file_hash_index.keys() { + let Some(bytes) = self + .load_file_snapshot(workspace_id, &meta.commit_id, path) + .await? + else { + continue; + }; + let hash = sha256_hex(&bytes); + let is_text = std::str::from_utf8(&bytes).is_ok(); + state.insert( + path.clone(), + FileSnapshot { + hash, + data: FileSnapshotData::Inline(bytes), + is_text, + }, + ); + } + Ok(state) + } + + async fn apply_state_to_workspace( + &self, + workspace_id: Uuid, + state: &HashMap, + previous_index: &HashMap, + ) -> anyhow::Result { + let mut changed: u32 = 0; + // write/update files + for (path, snapshot) in state.iter() { + let rel = format!("{}/{}", workspace_id, path.trim_start_matches('/')); + let abs = self.storage.absolute_from_relative(&rel); + if let Some(parent) = abs.parent() { + async_fs::create_dir_all(parent).await?; + } + let bytes = self.snapshot_bytes(snapshot).await?; + self.storage.write_bytes(abs.as_path(), &bytes).await?; + changed += 1; + } + // remove files missing in next state + for path in previous_index.keys() { + if state.contains_key(path) { + continue; + } + let rel = format!("{}/{}", workspace_id, path.trim_start_matches('/')); + let abs = self.storage.absolute_from_relative(&rel); + if async_fs::remove_file(&abs).await.is_ok() { + changed += 1; + } + } + Ok(changed) + } + + async fn ensure_folder( + &self, + workspace_id: Uuid, + actor_id: Uuid, + folder_path: &str, + cache: &mut HashMap, + ) -> anyhow::Result> { + let trimmed = folder_path.trim_matches('/'); + if trimmed.is_empty() { + return Ok(None); + } + + let mut current_parent: Option = None; + let mut accumulated = String::new(); + for segment in trimmed.split('/') { + if !accumulated.is_empty() { + accumulated.push('/'); + } + accumulated.push_str(segment); + + if let Some(id) = cache.get(&accumulated) { + current_parent = Some(*id); + continue; + } + + let lookup_path = format!("{}/{}", workspace_id, accumulated); + if let Some(existing) = self + .docs + .get_by_owner_and_path(workspace_id, &lookup_path) + .await? + { + if existing.doc_type != "folder" { + anyhow::bail!("path_conflict_not_folder"); + } + cache.insert(accumulated.clone(), existing.id); + current_parent = Some(existing.id); + continue; + } + + let title = if segment.trim().is_empty() { + "folder" + } else { + segment + }; + let folder = self + .docs + .create_for_user( + workspace_id, + actor_id, + title, + current_parent, + "folder", + None, + ) + .await?; + self.docs + .update_repo_path(folder.id, workspace_id, &accumulated) + .await?; + + cache.insert(accumulated.clone(), folder.id); + current_parent = Some(folder.id); + } + + Ok(current_parent) + } + + async fn materialize_documents_from_state( + &self, + workspace_id: Uuid, + actor_id: Uuid, + state: &HashMap, + ) -> anyhow::Result<(u32, u32)> { + fn folder_key(path: &str) -> String { + path.rsplitn(2, '/') + .nth(1) + .map(|s| s.trim().trim_end_matches('/').to_string()) + .filter(|s| !s.is_empty()) + .unwrap_or_else(String::new) + } + + fn attachment_owner_folder(path: &str) -> String { + if let Some(idx) = path.find("/attachments/") { + let prefix = &path[..idx]; + if prefix.is_empty() { + String::new() + } else { + prefix.trim_end_matches('/').to_string() + } + } else if path.starts_with("attachments/") { + String::new() + } else { + folder_key(path) + } + } + + fn is_markdown_path(path: &str) -> bool { + let lower = path.to_ascii_lowercase(); + lower.ends_with(".md") || lower.ends_with(".markdown") + } + + let mut folder_cache: HashMap = HashMap::new(); + let mut docs_created: u32 = 0; + let mut attachments_created: u32 = 0; + + let mut existing_by_desired: HashMap = HashMap::new(); + let mut folder_docs: HashMap> = HashMap::new(); + + for doc in self.docs.list_workspace_documents(workspace_id).await? { + let normalized = normalize_repo_path(doc.desired_path.clone()); + existing_by_desired.insert(normalized.clone(), doc.id); + if doc.doc_type != "folder" { + let key = folder_key(&normalized); + folder_docs.entry(key.clone()).or_default().push(doc.id); + if doc.archived_at.is_some() { + let archived_key = if key.is_empty() { + "Archives".to_string() + } else { + format!("Archives/{}", key) + }; + folder_docs.entry(archived_key).or_default().push(doc.id); + } + } + } + + let mut paths: Vec = state.keys().cloned().collect(); + paths.sort(); + + // First pass: create documents only for markdown files + for path in paths.iter() { + let snapshot = match state.get(path) { + Some(s) => s, + None => continue, + }; + if !snapshot.is_text { + continue; + } + let normalized = normalize_repo_path(path.clone()); + if !is_markdown_path(&normalized) { + continue; + } + + // Skip if document already exists at desired_path (including folders that would conflict) + if existing_by_desired.contains_key(&normalized) { + continue; + } + + let parent_path = folder_key(&normalized); + let parent_id = if parent_path.is_empty() { + None + } else { + self.ensure_folder(workspace_id, actor_id, &parent_path, &mut folder_cache) + .await? + }; + + let filename = normalized + .rsplit('/') + .next() + .unwrap_or(&normalized) + .to_string(); + let title = filename + .trim_end_matches(".md") + .trim_end_matches(".markdown") + .trim_end_matches(".txt"); + + let doc = self + .docs + .create_for_user( + workspace_id, + actor_id, + if title.is_empty() { "Document" } else { title }, + parent_id, + "document", + None, + ) + .await?; + self.docs + .update_repo_path(doc.id, workspace_id, &normalized) + .await?; + docs_created += 1; + existing_by_desired.insert(normalized.clone(), doc.id); + + folder_docs.entry(parent_path).or_default().push(doc.id); + + let bytes = self.snapshot_bytes(snapshot).await.unwrap_or_default(); + let body = extract_markdown_body(&bytes) + .unwrap_or_else(|| std::str::from_utf8(&bytes).unwrap_or_default().to_string()); + let snap_bytes = snapshot_from_markdown(&body); + let _ = self + .realtime + .apply_snapshot(&doc.id.to_string(), snap_bytes.as_slice()) + .await; + let _ = self.realtime.force_persist(&doc.id.to_string()).await; + } + + for docs in folder_docs.values_mut() { + docs.sort(); + } + + // Second pass: attach binaries without creating documents + for path in paths { + let snapshot = match state.get(&path) { + Some(s) => s, + None => continue, + }; + if snapshot.is_text { + continue; + } + let normalized = normalize_repo_path(path.clone()); + if !normalized.contains("/attachments/") && !normalized.starts_with("attachments/") { + continue; + } + let filename = normalized + .rsplit('/') + .next() + .unwrap_or(&normalized) + .to_string(); + let folder = attachment_owner_folder(&normalized); + let doc_id = folder_docs.get(&folder).and_then(|v| v.first().copied()); + let Some(doc_id) = doc_id else { + warn!( + workspace_id = %workspace_id, + repo_path = normalized.as_str(), + "git_materialize_attachment_no_owner" + ); + continue; + }; + + let storage_path = format!("{}/{}", workspace_id, normalized); + let existing: Option = + sqlx::query_scalar("SELECT id FROM files WHERE storage_path = $1 LIMIT 1") + .bind(&storage_path) + .fetch_optional(&self.pool) + .await?; + if existing.is_some() { + continue; + } + + let bytes = self.snapshot_bytes(snapshot).await.unwrap_or_default(); + let size = bytes.len() as i64; + let _ = sqlx::query( + r#"INSERT INTO files (document_id, filename, content_type, size, storage_path, content_hash) + VALUES ($1,$2,$3,$4,$5,$6)"#, + ) + .bind(doc_id) + .bind(&filename) + .bind::>(None) + .bind(size) + .bind(&storage_path) + .bind(&snapshot.hash) + .execute(&self.pool) + .await?; + attachments_created += 1; + } + Ok((docs_created, attachments_created)) + } + + /// Apply merged markdown files directly to realtime/persistence so documents reflect Pull results. + async fn apply_merged_to_documents( + &self, + workspace_id: Uuid, + next_state: &HashMap, + ) -> anyhow::Result<()> { + let doc_rows = self + .docs + .list_workspace_documents(workspace_id) + .await? + .into_iter() + .filter(|d| d.doc_type != "folder"); + + for doc in doc_rows { + let doc_id = doc.id; + let normalized = normalize_repo_path(doc.desired_path.clone()); + let Some(snapshot) = next_state.get(&normalized) else { + continue; + }; + + if !snapshot.is_text { + continue; + } + let bytes = match self.snapshot_bytes(snapshot).await { + Ok(b) => b, + Err(err) => { + warn!(document_id = %doc_id, error = ?err, "git_pull_snapshot_bytes_failed"); + continue; + } + }; + let body = match extract_markdown_body(&bytes) { + Some(b) => b, + None => continue, + }; + let snap_bytes = + crate::application::services::realtime::snapshot::snapshot_from_markdown(&body); + if let Err(err) = crate::infrastructure::storage::suppress_git_dirty(async { + self.realtime + .apply_snapshot(&doc_id.to_string(), snap_bytes.as_slice()) + .await?; + self.realtime.force_persist(&doc_id.to_string()).await + }) + .await + { + warn!(document_id = %doc_id, error = ?err, "git_pull_apply_snapshot_failed"); + continue; + } + } + Ok(()) + } + fn build_diff_result( &self, path: &str, @@ -1091,6 +1535,63 @@ impl GitWorkspaceService { Ok(results) } + + // Build a synthetic commit from the current workspace state so dirty edits participate in merges. + fn build_synthetic_commit( + &self, + workspace_id: Uuid, + repo: &Repository, + base_oid: git2::Oid, + ) -> anyhow::Result { + // Collect current workspace state into blobs and index entries (supports nested paths). + let current_state = tokio::task::block_in_place(|| { + let handle = tokio::runtime::Handle::current(); + handle.block_on(self.collect_current_state(workspace_id)) + })?; + + let mut index = repo.index()?; + index.clear()?; + + for (path, snapshot) in current_state.iter() { + let bytes = tokio::task::block_in_place(|| { + let handle = tokio::runtime::Handle::current(); + handle.block_on(self.snapshot_bytes(snapshot)) + })?; + let blob_oid = repo.blob(&bytes)?; + + let entry = git2::IndexEntry { + ctime: git2::IndexTime::new(0, 0), + mtime: git2::IndexTime::new(0, 0), + dev: 0, + ino: 0, + mode: 0o100644, + uid: 0, + gid: 0, + file_size: bytes.len() as u32, + id: blob_oid, + flags: std::cmp::min(path.as_bytes().len(), 0x0fff) as u16, + flags_extended: 0, + path: path.as_bytes().to_vec(), + }; + index.add(&entry)?; + } + + let tree_oid = index.write_tree_to(repo)?; + let tree = repo.find_tree(tree_oid)?; + + // Create a synthetic commit with remote as parent to anchor the merge base. + // Use an explicit signature so we don't rely on local git config being present. + let sig = signature_from_parts("RefMD", "refmd@example.com", Utc::now())?; + let commit_oid = repo.commit( + Some("refs/heads/synthetic-workspace"), + &sig, + &sig, + "workspace-state", + &tree, + &[&repo.find_commit(base_oid)?], + )?; + Ok(commit_oid) + } } #[async_trait] @@ -1391,10 +1892,10 @@ impl GitWorkspacePort for GitWorkspaceService { if latest_meta.is_none() { if let Some(cfg) = cfg { if !cfg.repository_url.is_empty() { - // Best-effort attempt to bootstrap remote history; ignore errors (e.g., redirects or auth loops) - let _ = self - .bootstrap_remote_history(workspace_id, cfg, branch_hint.as_str()) - .await; + // Bootstrap remote history; propagate errors to avoid proceeding without packs. + self.bootstrap_remote_history(workspace_id, cfg, branch_hint.as_str()) + .await?; + latest_meta = self.ensure_latest_meta(workspace_id).await?; } } } @@ -1406,8 +1907,69 @@ impl GitWorkspacePort for GitWorkspaceService { let force_push = req.force.unwrap_or(false); let force_full_scan = req.full_scan.unwrap_or(false); let skip_push = req.skip_push.unwrap_or(false); + let push_required = cfg + .as_ref() + .map(|c| !c.repository_url.is_empty()) + .unwrap_or(false) + && !skip_push; - latest_meta = self.ensure_latest_meta(workspace_id).await?; + // Ensure latest commit pack exists; if missing, attempt to rebuild from storage/remote or fail early. + if let Some(latest) = latest_meta.as_ref() { + if self + .git_storage + .fetch_pack_for_commit(workspace_id, latest.commit_id.as_slice()) + .await? + .is_none() + { + // Try to restore metadata and pack from storage (if pointer mismatch), else try remote bootstrap. + warn!( + workspace_id = %workspace_id, + commit = %encode_commit_id(&latest.commit_id), + "git_sync_missing_latest_pack_detected" + ); + // Attempt backfill from storage; ensure_latest_meta will also update latest pointer. + self.ensure_storage_commit_integrity(workspace_id).await?; + latest_meta = self.ensure_latest_meta(workspace_id).await?; + if let Some(latest2) = latest_meta.as_ref() { + if self + .git_storage + .fetch_pack_for_commit(workspace_id, latest2.commit_id.as_slice()) + .await? + .is_none() + { + if let Some(cfg) = cfg { + if !cfg.repository_url.is_empty() { + info!( + workspace_id = %workspace_id, + commit = %encode_commit_id(&latest2.commit_id), + "git_sync_missing_latest_pack_bootstrap_remote" + ); + self.bootstrap_remote_history( + workspace_id, + cfg, + branch_hint.as_str(), + ) + .await?; + latest_meta = self.ensure_latest_meta(workspace_id).await?; + } + } + } + } + if let Some(latest3) = latest_meta.as_ref() { + if self + .git_storage + .fetch_pack_for_commit(workspace_id, latest3.commit_id.as_slice()) + .await? + .is_none() + { + anyhow::bail!( + "missing pack data for latest commit {}; pull and retry", + encode_commit_id(&latest3.commit_id) + ); + } + } + } + } let mut storage_latest = self.git_storage.latest_commit(workspace_id).await?; let mut storage_commit_hex = storage_latest @@ -1479,15 +2041,14 @@ impl GitWorkspacePort for GitWorkspaceService { self.ensure_storage_commit_integrity(workspace_id).await?; latest_meta = self.latest_commit_meta(workspace_id).await?; + let use_full_scan = force_full_scan || latest_meta.is_none(); + let previous_index = latest_meta .as_ref() .map(|c| c.file_hash_index.clone()) .unwrap_or_default(); let dirty_rows = self.fetch_dirty(workspace_id).await?; - // Determine strategy: forced full scan or initial commit uses full state rebuild. - let use_full_scan = force_full_scan || latest_meta.is_none(); - // Build change sets from dirty rows let mut upserts: BTreeMap = BTreeMap::new(); let mut deletes: BTreeSet = BTreeSet::new(); @@ -1524,9 +2085,37 @@ impl GitWorkspacePort for GitWorkspaceService { ); } - // If still nothing to do + // If still nothing to do, optionally push existing head when a remote is configured. if !use_full_scan && upserts.is_empty() && deletes.is_empty() { - // Nothing to commit: clear any leftover dirty and exit. + if push_required { + if let Some(latest) = latest_meta.as_ref() { + // Ensure pack chain exists to materialize the commit for push. + let pack_chain = self + .persist_pack_chain(workspace_id, Some(latest.commit_id.as_slice())) + .await?; + if let Some((temp_dir, pack_paths)) = pack_chain { + let repo = Repository::init_bare(temp_dir.path())?; + apply_pack_files(&repo, &pack_paths)?; + let oid = git2::Oid::from_bytes(&latest.commit_id)?; + let pushed = + perform_push(&repo, cfg.unwrap(), &branch_name, oid, force_push)?; + drop(repo); + drop(temp_dir); + let _ = self.clear_dirty(workspace_id).await; + return Ok(GitSyncOutcome { + files_changed: 0, + commit_hash: Some(encode_commit_id(&latest.commit_id)), + pushed, + message: if pushed { + "push completed".to_string() + } else { + "nothing to push".to_string() + }, + }); + } + } + } + // Nothing to commit/push: clear any leftover dirty and exit. let _ = self.clear_dirty(workspace_id).await; return Ok(GitSyncOutcome { files_changed: 0, @@ -1550,9 +2139,11 @@ impl GitWorkspacePort for GitWorkspaceService { let mut precomputed_upsert_bytes: BTreeMap> = BTreeMap::new(); let mut changed_text_snapshots: HashMap = HashMap::new(); let mut next_file_hash_index: HashMap = previous_index.clone(); - let files_changed_for_response: u32; + let mut files_changed_for_response: u32; if use_full_scan { + // Rebuild full-scan data fresh in case we fell back here after a pack failure. + next_file_hash_index.clear(); let current = self.collect_current_state(workspace_id).await?; let mut entries: BTreeMap> = BTreeMap::new(); for (path, snapshot) in current.iter() { @@ -1631,22 +2222,52 @@ impl GitWorkspacePort for GitWorkspaceService { files_changed_for_response = (upserts.len() + deletes.len()) as u32; } - let previous_pack = if let Some(prev_meta) = latest_meta.as_ref() { - Some( - self.persist_pack_chain(workspace_id, Some(prev_meta.commit_id.as_slice())) - .await? - .ok_or_else(|| { - anyhow!( - "missing pack data for commit {}", - encode_commit_id(&prev_meta.commit_id) - ) - })?, - ) - } else { - None - }; + let mut previous_pack = None; + if let Some(prev_meta) = latest_meta.as_ref() { + let prev_commit_hex = encode_commit_id(&prev_meta.commit_id); + match self + .persist_pack_chain(workspace_id, Some(prev_meta.commit_id.as_slice())) + .await? + { + Some(chain) => { + previous_pack = Some(chain); + } + None => { + // Attempt to repair from remote and retry once. + if let Some(cfg) = cfg { + if !cfg.repository_url.is_empty() { + warn!( + workspace_id = %workspace_id, + commit = %prev_commit_hex, + "git_sync_missing_pack_chain_recover" + ); + self.recover_missing_objects(workspace_id, cfg).await?; + latest_meta = self.ensure_latest_meta(workspace_id).await?; + if let Some(latest) = latest_meta.as_ref() { + previous_pack = self + .persist_pack_chain( + workspace_id, + Some(latest.commit_id.as_slice()), + ) + .await?; + } + } + } + if previous_pack.is_none() { + warn!( + workspace_id = %workspace_id, + "git_sync_missing_pack_chain_abort" + ); + anyhow::bail!( + "missing pack data for current head {}; pull/import required before sync", + prev_commit_hex + ); + } + } + } + } - let (meta, pack_bytes, commit_hex, pushed, files_changed_for_response) = { + let (meta, pack_bytes, commit_hex, pushed) = { let temp_dir = TempDirBuilder::new() .prefix("git-sync-") .tempdir() @@ -1655,13 +2276,91 @@ impl GitWorkspacePort for GitWorkspaceService { if let Some((_, ref pack_paths)) = previous_pack { // Apply full chain to ensure delta bases are present - apply_pack_files(&repo, pack_paths)?; + if let Err(err) = apply_pack_files(&repo, pack_paths) { + let lower = err.to_string().to_lowercase(); + let missing_obj = lower.contains("missing") && lower.contains("object"); + if missing_obj { + // Try to repair packs by re-bootstrap from remote, then retry apply once more. + warn!( + workspace_id = %workspace_id, + error = %err, + "git_sync_pack_missing_objects_retry_bootstrap" + ); + if let Some(cfg) = cfg { + if !cfg.repository_url.is_empty() { + let branch = branch_name.clone(); + self.bootstrap_remote_history(workspace_id, cfg, branch.as_str()) + .await?; + previous_pack = self + .persist_pack_chain( + workspace_id, + latest_meta.as_ref().map(|m| m.commit_id.as_slice()), + ) + .await?; + if let Some((_, ref pack_paths_retry)) = previous_pack { + if apply_pack_files(&repo, pack_paths_retry).is_err() { + // Last resort: recover objects and retry once more. + warn!( + workspace_id = %workspace_id, + "git_sync_pack_retry_still_missing_recovering_objects" + ); + self.recover_missing_objects(workspace_id, cfg).await?; + latest_meta = self.ensure_latest_meta(workspace_id).await?; + previous_pack = self + .persist_pack_chain( + workspace_id, + latest_meta + .as_ref() + .map(|m| m.commit_id.as_slice()), + ) + .await?; + if let Some((_, ref pack_paths_retry2)) = previous_pack { + apply_pack_files(&repo, pack_paths_retry2)?; + } else { + anyhow::bail!( + "missing pack objects after recovery; pull/import required before sync" + ); + } + } + } else { + anyhow::bail!( + "missing pack objects after bootstrap; pull/import required before sync" + ); + } + } + } + anyhow::bail!( + "missing pack objects for {}; pull/import to repair history", + latest_meta + .as_ref() + .map(|m| encode_commit_id(&m.commit_id)) + .unwrap_or_else(|| "unknown".to_string()) + ); + } else { + return Err(err); + } + } } // Skip pre-fetch/verify to avoid remote redirect/auth loops; rely on push outcome. // Build sources from either full scan or dirty set (no awaits here) let tree_oid = if use_full_scan { - let entries = precomputed_full_entries.as_ref().unwrap(); + if precomputed_full_entries.is_none() { + // We fell back to full-scan after a pack failure; rebuild snapshots fresh. + next_file_hash_index.clear(); + let current = self.collect_current_state(workspace_id).await?; + let mut entries: BTreeMap> = BTreeMap::new(); + for (path, snapshot) in current.iter() { + let bytes = self.snapshot_bytes(snapshot).await?; + entries.insert(path.clone(), bytes); + next_file_hash_index.insert(path.clone(), snapshot.hash.clone()); + } + files_changed_for_response = next_file_hash_index.len() as u32; + precomputed_full_entries = Some(entries); + } + let entries = precomputed_full_entries + .as_ref() + .ok_or_else(|| anyhow!("full-scan entries missing"))?; build_tree_from_entries(&repo, entries)? } else { // Incremental: reuse previous blobs for unchanged paths @@ -1704,6 +2403,10 @@ impl GitWorkspacePort for GitWorkspaceService { let mut pack_builder = repo.packbuilder()?; pack_builder.insert_commit(commit_oid)?; + // Include parent commit objects to avoid missing bases when applying packs later. + for parent in parent_commits.iter() { + pack_builder.insert_commit(parent.id())?; + } let mut pack_buf = git2::Buf::new(); pack_builder.write_buf(&mut pack_buf)?; let pack_bytes = pack_buf.to_vec(); @@ -1745,37 +2448,1229 @@ impl GitWorkspacePort for GitWorkspaceService { // files_changed_for_response computed earlier - ( - meta, - pack_bytes, - commit_hex, - pushed, - files_changed_for_response, - ) + (meta, pack_bytes, commit_hex, pushed) }; if let Some((dir, _)) = previous_pack { drop(dir); } - // Short, focused transaction for DB writes only. + // If push to a configured remote failed, do not advance local commit pointers or clear dirty state. + // Leave files as-is so the next sync attempt will retry the push instead of treating the workspace as clean. + if push_required && !pushed { + return Ok(GitSyncOutcome { + files_changed: files_changed_for_response, + commit_hash: None, + pushed: false, + message: "commit created (push failed)".to_string(), + }); + } + + // Short, focused transaction for DB writes only. + let mut tx = self.pool.begin().await?; + // Recheck repository state exists before writing. + let repo_row2 = + sqlx::query("SELECT initialized FROM git_repository_state WHERE workspace_id = $1") + .bind(workspace_id) + .fetch_optional(&mut *tx) + .await?; + let Some(repo_row2) = repo_row2 else { + tx.rollback().await.ok(); + anyhow::bail!("repository not initialized") + }; + let initialized2: bool = repo_row2.get("initialized"); + if !initialized2 { + tx.rollback().await.ok(); + anyhow::bail!("repository not initialized") + } + + sqlx::query( + r#"INSERT INTO git_commits ( + commit_id, + parent_commit_id, + workspace_id, + message, + author_name, + author_email, + committed_at, + pack_key, + file_hash_index + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)"#, + ) + .bind(meta.commit_id.clone()) + .bind(meta.parent_commit_id.clone()) + .bind(workspace_id) + .bind(meta.message.clone()) + .bind(meta.author_name.clone()) + .bind(meta.author_email.clone()) + .bind(meta.committed_at) + .bind(meta.pack_key.clone()) + .bind(Json(&meta.file_hash_index)) + .execute(&mut *tx) + .await?; + + sqlx::query("UPDATE git_repository_state SET updated_at = now() WHERE workspace_id = $1") + .bind(workspace_id) + .execute(&mut *tx) + .await?; + + // Only store snapshots for changed text files (incremental), or all in initial full scan + let snapshot_keys = if use_full_scan { + // full state snapshot + let current = self.collect_current_state(workspace_id).await?; + match self + .store_commit_snapshots(workspace_id, &meta.commit_id, ¤t) + .await + { + Ok(keys) => keys, + Err(err) => { + tx.rollback().await.ok(); + return Err(err); + } + } + } else { + match self + .store_commit_snapshots(workspace_id, &meta.commit_id, &changed_text_snapshots) + .await + { + Ok(keys) => keys, + Err(err) => { + tx.rollback().await.ok(); + return Err(err); + } + } + }; + + if let Err(err) = self + .git_storage + .store_pack(workspace_id, &pack_bytes, &meta) + .await + { + for key in snapshot_keys.iter().rev() { + let _ = self.git_storage.delete_blob(key).await; + } + tx.rollback().await.ok(); + return Err(err); + } + + if let Err(err) = self + .git_storage + .set_latest_commit(workspace_id, Some(&meta)) + .await + { + let _ = self + .git_storage + .delete_pack(workspace_id, &meta.commit_id) + .await; + for key in snapshot_keys.iter().rev() { + let _ = self.git_storage.delete_blob(key).await; + } + tx.rollback().await.ok(); + return Err(err); + } + + if let Err(err) = tx.commit().await { + let _ = self + .git_storage + .delete_pack(workspace_id, &meta.commit_id) + .await; + for key in snapshot_keys.iter().rev() { + let _ = self.git_storage.delete_blob(key).await; + } + let _ = self + .git_storage + .set_latest_commit(workspace_id, latest_meta.as_ref()) + .await; + return Err(err.into()); + } + + // Best-effort clear of processed dirty entries + self.clear_dirty(workspace_id).await.map_err(|err| { + error!(workspace_id = %workspace_id, error = %err, "git_import_clear_dirty_failed"); + err + })?; + let outcome_message = if pushed { + "sync completed".to_string() + } else if skip_push { + "sync completed (push skipped)".to_string() + } else { + "commit created (push failed)".to_string() + }; + + Ok(GitSyncOutcome { + files_changed: files_changed_for_response, + commit_hash: Some(commit_hex), + pushed, + message: outcome_message, + }) + } + + async fn import_repository( + &self, + workspace_id: Uuid, + actor_id: Uuid, + cfg: &UserGitCfg, + ) -> anyhow::Result { + // Suppress dirty tracking globally during import so filesystem watcher/ingest won't re-mark files. + let _global_dirty_guard = crate::infrastructure::storage::suppress_git_dirty_global(); + let branch = if cfg.branch_name.is_empty() { + "main".to_string() + } else { + cfg.branch_name.clone() + }; + self.ensure_repository(workspace_id, &branch).await?; + + let previous_index = self + .latest_commit_meta(workspace_id) + .await? + .map(|m| m.file_hash_index) + .unwrap_or_default(); + + // Populate storage and DB with remote history; surface errors so we don't proceed with missing packs. + self.bootstrap_remote_history(workspace_id, cfg, branch.as_str()) + .await?; + let latest = self.ensure_latest_meta(workspace_id).await?; + let Some(latest_meta) = latest else { + return Ok(GitImportOutcome { + files_changed: 0, + commit_hash: None, + docs_created: 0, + attachments_created: 0, + message: "remote has no commits".to_string(), + }); + }; + + let state = self + .state_from_commit_meta(workspace_id, &latest_meta) + .await?; + let files_changed = crate::infrastructure::storage::suppress_git_dirty(async { + self.apply_state_to_workspace(workspace_id, &state, &previous_index) + .await + }) + .await?; + + // Materialize documents and attachments from imported state; surface failures so Import can fail loudly. + let (docs_created, attachments_created) = + crate::infrastructure::storage::suppress_git_dirty(async { + self.materialize_documents_from_state(workspace_id, actor_id, &state) + .await + }) + .await?; + + self.apply_merged_to_documents(workspace_id, &state).await?; + self.clear_dirty(workspace_id).await.map_err(|err| { + error!(workspace_id = %workspace_id, error = %err, "git_import_clear_dirty_failed"); + err + })?; + + Ok(GitImportOutcome { + files_changed, + docs_created, + attachments_created, + commit_hash: Some(encode_commit_id(&latest_meta.commit_id)), + message: "import completed".to_string(), + }) + } + + async fn pull( + &self, + workspace_id: Uuid, + actor_id: Uuid, + req: &GitPullRequestDto, + cfg: &UserGitCfg, + ) -> anyhow::Result { + let mut recover_attempts: u8 = 0; + let mut skip_local_pack_restore = false; + loop { + match self + .pull_once(workspace_id, actor_id, req, cfg, skip_local_pack_restore) + .await + { + Ok(dto) => return Ok(dto), + Err(err) => { + if Self::is_missing_objects(&err) { + if recover_attempts < 2 { + recover_attempts += 1; + skip_local_pack_restore = true; + warn!( + workspace_id = %workspace_id, + attempt = %recover_attempts, + error = %err, + "git_pull_missing_objects_recovering" + ); + self.recover_missing_objects(workspace_id, cfg).await?; + continue; + } + } + return Err(err); + } + } + } + } + + async fn head_commit(&self, workspace_id: Uuid) -> anyhow::Result>> { + Ok(self + .latest_commit_meta(workspace_id) + .await? + .map(|m| m.commit_id)) + } + + async fn remote_head( + &self, + workspace_id: Uuid, + cfg: &UserGitCfg, + ) -> anyhow::Result>> { + let state = self.load_repository_state(workspace_id).await?; + let Some((initialized, branch_default)) = state else { + anyhow::bail!("repository not initialized"); + }; + if !initialized { + anyhow::bail!("repository not initialized"); + } + if cfg.repository_url.is_empty() { + anyhow::bail!("remote not configured"); + } + let branch = if cfg.branch_name.is_empty() { + branch_default + } else { + cfg.branch_name.clone() + }; + let temp_dir = TempDirBuilder::new() + .prefix("git-remote-head-") + .tempdir() + .map_err(|e| anyhow!(e))?; + let repo = Repository::init_bare(temp_dir.path())?; + let head = fetch_remote_head(&repo, cfg, &branch)?; + Ok(head.map(|oid| oid.as_bytes().to_vec())) + } + + async fn has_pending_changes(&self, workspace_id: Uuid) -> anyhow::Result { + let dirty_rows = self.fetch_dirty(workspace_id).await?; + Ok(!dirty_rows.is_empty()) + } + + async fn drift_since_commit( + &self, + workspace_id: Uuid, + base_commit: &[u8], + ) -> anyhow::Result { + let Some(meta) = self.commit_meta_by_id(workspace_id, base_commit).await? else { + return Ok(true); + }; + let base_index = meta.file_hash_index; + let current_state = self.collect_current_state(workspace_id).await?; + if base_index.len() != current_state.len() { + return Ok(true); + } + for (path, snapshot) in current_state.into_iter() { + let Some(base_hash) = base_index.get(&path) else { + return Ok(true); + }; + if base_hash != &snapshot.hash { + return Ok(true); + } + } + Ok(false) + } + + async fn check_remote( + &self, + workspace_id: Uuid, + cfg: &UserGitCfg, + ) -> anyhow::Result { + if cfg.repository_url.is_empty() { + return Ok(GitRemoteCheckDto { + ok: true, + message: "remote not configured".to_string(), + reason: Some("no_remote".to_string()), + }); + } + let branch = cfg.branch_name.clone(); + let temp_dir = TempDirBuilder::new() + .prefix("git-check-") + .tempdir() + .map_err(|e| anyhow!(e))?; + let repo = Repository::init_bare(temp_dir.path())?; + let result = match fetch_remote_head(&repo, cfg, &branch) { + Ok(Some(_)) => GitRemoteCheckDto { + ok: true, + message: "remote reachable".to_string(), + reason: None, + }, + Ok(None) => GitRemoteCheckDto { + ok: false, + message: format!("branch '{branch}' not found on remote"), + reason: Some("branch_missing".to_string()), + }, + Err(err) => { + let lower = err.to_string().to_lowercase(); + let (reason, msg) = if lower.contains("git_http_auth_redirect") { + ( + Some("auth_required".to_string()), + "remote requires authentication or SSO approval".to_string(), + ) + } else if lower.contains("git_http_not_found") || lower.contains("status code: 404") + { + ( + Some("repo_not_found".to_string()), + "repository URL or branch not found".to_string(), + ) + } else { + (None, err.to_string()) + }; + GitRemoteCheckDto { + ok: false, + message: msg, + reason, + } + } + }; + drop(repo); + let _ = temp_dir.close(); + info!(workspace_id = %workspace_id, ok = %result.ok, reason = ?result.reason, "git_remote_check_completed"); + Ok(result) + } +} + +impl GitWorkspaceService { + async fn build_conflict_item( + &self, + workspace_id: Uuid, + path: &str, + current_state: &HashMap, + remote_state: &HashMap, + local_meta: Option<&CommitMeta>, + ) -> anyhow::Result { + let ours_bytes = if let Some(snap) = current_state.get(path) { + Some(self.snapshot_bytes(snap).await?) + } else { + None + }; + let theirs_bytes = if let Some(snap) = remote_state.get(path) { + Some(self.snapshot_bytes(snap).await?) + } else { + Some(Vec::new()) + }; + let base_bytes = if let Some(meta) = local_meta.as_ref() { + self.load_file_snapshot(workspace_id, meta.commit_id.as_slice(), path) + .await? + } else { + None + }; + + let (mut ours, ours_bin) = as_text_or_binary(path, ours_bytes.as_ref()); + let (mut theirs, theirs_bin) = as_text_or_binary(path, theirs_bytes.as_ref()); + let (mut base, base_bin) = as_text_or_binary(path, base_bytes.as_ref()); + let is_binary = ours_bin || theirs_bin || base_bin; + if !is_binary { + ours = strip_front_matter_body(path, ours); + theirs = strip_front_matter_body(path, theirs); + base = strip_front_matter_body(path, base); + } + + Ok(GitPullConflictItemDto { + path: path.to_string(), + is_binary, + ours, + theirs, + base, + document_id: None, + }) + } + + async fn pull_once( + &self, + workspace_id: Uuid, + actor_id: Uuid, + req: &GitPullRequestDto, + cfg: &UserGitCfg, + skip_local_pack_restore: bool, + ) -> anyhow::Result { + let state = self.load_repository_state(workspace_id).await?; + let Some((initialized, branch_default)) = state else { + anyhow::bail!("repository not initialized"); + }; + if !initialized { + anyhow::bail!("repository not initialized"); + } + if cfg.repository_url.is_empty() { + anyhow::bail!("remote not configured"); + } + + let branch = if cfg.branch_name.is_empty() { + branch_default + } else { + cfg.branch_name.clone() + }; + + // Capture current workspace head before touching remote history. + let mut local_meta = self.latest_commit_meta(workspace_id).await?; + // After a recovery we want to treat pull as a fresh fast-forward from remote. + if skip_local_pack_restore { + local_meta = None; + } + let mut local_history_reset = false; + let mut base_index: HashMap = local_meta + .as_ref() + .map(|m| m.file_hash_index.clone()) + .unwrap_or_default(); + let mut previous_index = base_index.clone(); + let mut base_commit = local_meta.as_ref().map(|m| m.commit_id.clone()); + + let temp_dir = TempDirBuilder::new() + .prefix("git-pull-") + .tempdir() + .map_err(|e| anyhow::anyhow!(e))?; + let repo = Repository::init_bare(temp_dir.path())?; + if !skip_local_pack_restore { + match self + .persist_pack_chain( + workspace_id, + local_meta.as_ref().map(|m| m.commit_id.as_slice()), + ) + .await? + { + Some((_, pack_paths)) => { + apply_pack_files(&repo, &pack_paths)?; + } + None => { + warn!( + workspace_id = %workspace_id, + "git_pull_pack_restore_missing_resetting_base" + ); + // Storage/DB history was reset; treat as fresh pull with no local history. + local_meta = None; + local_history_reset = true; + base_index.clear(); + previous_index.clear(); + base_commit = None; + } + } + } else { + info!(workspace_id = %workspace_id, "git_pull_skip_local_pack_restore"); + } + + let remote_oid = { + let Some(head) = fetch_remote_head(&repo, cfg, &branch)? else { + return Ok(GitPullResultDto { + success: false, + message: format!("branch '{branch}' not found on remote"), + files_changed: 0, + commit_hash: None, + conflicts: None, + base_commit: base_commit.clone(), + remote_commit: None, + }); + }; + head + }; + let remote_commit = Some(remote_oid.as_bytes().to_vec()); + + let mut local_oid = if local_history_reset { + None + } else { + local_meta + .as_ref() + .and_then(|m| git2::Oid::from_bytes(&m.commit_id).ok()) + }; + // If workspace has no local commit recorded (fresh pull), fall back to latest known meta after bootstrap. + if local_oid.is_none() && !skip_local_pack_restore && !local_history_reset { + if let Some(meta) = self.latest_commit_meta(workspace_id).await? { + base_index = meta.file_hash_index.clone(); + previous_index = base_index.clone(); + base_commit = Some(meta.commit_id.clone()); + local_oid = git2::Oid::from_bytes(&meta.commit_id).ok(); + local_meta = Some(meta); + } + } + // Detect drift between latest commit and current workspace using the same dirty set as Git Changes/Status. + let dirty_rows = self.fetch_dirty(workspace_id).await?; + let current_state = self.collect_current_state(workspace_id).await?; + info!(workspace_id = %workspace_id, dirty_count = dirty_rows.len(), skip_local_pack_restore = skip_local_pack_restore, "git_pull_dirty_state"); + + #[derive(Clone, Copy, PartialEq, Eq)] + enum CommitRelation { + NoLocal, + Same, + LocalAhead, + RemoteAhead, + Diverged, + } + + let commit_relation = if let Some(local_oid_val) = local_oid { + if local_oid_val == remote_oid { + CommitRelation::Same + } else if repo.graph_descendant_of(local_oid_val, remote_oid)? { + CommitRelation::LocalAhead + } else if repo.graph_descendant_of(remote_oid, local_oid_val)? { + CommitRelation::RemoteAhead + } else { + CommitRelation::Diverged + } + } else { + CommitRelation::NoLocal + }; + + // Nothing to do when remote is identical to or behind the local head. + if matches!( + commit_relation, + CommitRelation::Same | CommitRelation::LocalAhead + ) { + let commit_hash = local_oid + .as_ref() + .map(|oid| encode_commit_id(oid.as_bytes())); + return Ok(GitPullResultDto { + success: true, + message: "no remote changes".to_string(), + files_changed: 0, + commit_hash, + conflicts: None, + base_commit: base_commit.clone(), + remote_commit: remote_commit.clone(), + }); + } + + // Build remote state directly from fetched pack (git2 tree), independent of DB meta. + fn collect_remote_state( + repo: &Repository, + oid: git2::Oid, + ) -> anyhow::Result> { + let commit = repo.find_commit(oid)?; + let tree = commit.tree()?; + let mut out: HashMap = HashMap::new(); + + fn walk( + repo: &Repository, + tree: &git2::Tree, + prefix: &str, + out: &mut HashMap, + ) -> anyhow::Result<()> { + for entry in tree.iter() { + let name = entry.name().unwrap_or_default(); + let path = if prefix.is_empty() { + name.to_string() + } else { + format!("{prefix}{name}") + }; + match entry.kind() { + Some(git2::ObjectType::Tree) => { + if let Some(sub) = entry.to_object(repo)?.as_tree() { + walk(repo, sub, &(path.clone() + "/"), out)?; + } + } + Some(git2::ObjectType::Blob) => { + let blob = repo.find_blob(entry.id())?; + let bytes = blob.content().to_vec(); + let hash = sha256_hex(&bytes); + let is_text = std::str::from_utf8(&bytes).is_ok(); + out.insert( + path, + FileSnapshot { + hash, + data: FileSnapshotData::Inline(bytes), + is_text, + }, + ); + } + _ => {} + } + } + Ok(()) + } + + walk(repo, &tree, "", &mut out)?; + Ok(out) + } + + let remote_state = collect_remote_state(&repo, remote_oid)?; + let mut remote_conflicts: Vec = Vec::new(); + let mut remote_changed_paths: HashSet = HashSet::new(); + for (path, snap) in remote_state.iter() { + if base_index.get(path) != Some(&snap.hash) { + remote_changed_paths.insert(path.clone()); + } + } + for path in base_index.keys() { + if !remote_state.contains_key(path) { + remote_changed_paths.insert(path.clone()); + } + } + let remote_changed_paths_vec: Vec = remote_changed_paths.iter().cloned().collect(); + for path in remote_changed_paths_vec.iter() { + let item = self + .build_conflict_item( + workspace_id, + path, + ¤t_state, + &remote_state, + local_meta.as_ref(), + ) + .await?; + remote_conflicts.push(item); + } + + // First-time pull with no local history and no dirty changes: allow fast-forward without forcing conflicts. + if local_meta.is_none() && dirty_rows.is_empty() { + remote_conflicts.clear(); + } + + // If commits differ but no conflict paths were detected above, fallback to diff of current vs remote trees. + if remote_conflicts.is_empty() { + let local_oid_val = local_oid.unwrap_or(remote_oid); + if remote_oid != local_oid_val { + let mut all_paths: HashSet = HashSet::new(); + for p in remote_state.keys() { + all_paths.insert(p.clone()); + } + for p in current_state.keys() { + all_paths.insert(p.clone()); + } + for path in all_paths { + let remote_hash = remote_state.get(&path).map(|s| &s.hash); + let local_hash = current_state.get(&path).map(|s| &s.hash); + if remote_hash == local_hash { + continue; + } + + let item = self + .build_conflict_item( + workspace_id, + &path, + ¤t_state, + &remote_state, + local_meta.as_ref(), + ) + .await?; + remote_conflicts.push(item); + } + } + } + let remote_changes = !remote_conflicts.is_empty(); + let remote_ahead_clean = + matches!(commit_relation, CommitRelation::RemoteAhead) && dirty_rows.is_empty(); + let fast_forward_remote = + matches!(commit_relation, CommitRelation::NoLocal) || remote_ahead_clean; + + // Detect overlap between remote-changed paths and dirty rows to avoid false conflicts. + let dirty_paths: HashSet = dirty_rows.iter().map(|r| r.path.clone()).collect(); + let dirty_remote_overlap = remote_changed_paths_vec + .iter() + .any(|p| dirty_paths.contains(p)); + + info!( + workspace_id = %workspace_id, + dirty_count = dirty_rows.len(), + remote_conflict_count = remote_conflicts.len(), + remote_changes = remote_changes, + resolutions_count = req.resolutions.len(), + dirty_remote_overlap = dirty_remote_overlap, + "git_pull_debug_state" + ); + + // If workspace has dirty changes overlapping remote changes, require explicit resolutions. + if remote_changes && dirty_remote_overlap && req.resolutions.is_empty() { + let conflicts = if remote_conflicts.is_empty() { + vec![GitPullConflictItemDto { + path: "".to_string(), + is_binary: false, + ours: None, + theirs: None, + base: None, + document_id: None, + }] + } else { + remote_conflicts.clone() + }; + return Ok(GitPullResultDto { + success: false, + message: "conflicts detected".to_string(), + files_changed: 0, + commit_hash: None, + conflicts: Some(conflicts), + base_commit: base_commit.clone(), + remote_commit: remote_commit.clone(), + }); + } + + // Ensure remote head commit metadata/pack exists locally for merge parent and future syncs. + let mut remote_pack: Option<(CommitMeta, Vec)> = None; + if self + .commit_meta_by_id(workspace_id, remote_oid.as_bytes()) + .await? + .is_none() + { + let remote_index: HashMap = remote_state + .iter() + .map(|(path, snap)| (path.clone(), snap.hash.clone())) + .collect(); + let (remote_meta, remote_pack_bytes) = { + let remote_commit_obj = repo.find_commit(remote_oid)?; + let committed_at = git_time_to_datetime(remote_commit_obj.time())?; + let message = remote_commit_obj + .message() + .map(|m| m.trim_end_matches('\n').to_string()) + .filter(|m| !m.trim().is_empty()); + let author = remote_commit_obj.author(); + let author_name = author.name().map(|s| s.to_string()); + let author_email = author.email().map(|s| s.to_string()); + let parent_commit_id = if remote_commit_obj.parent_count() > 0 { + let parent = remote_commit_obj.parent_id(0)?; + Some(parent.as_bytes().to_vec()) + } else { + None + }; + + let mut pack_builder = repo.packbuilder()?; + pack_builder.insert_commit(remote_oid)?; + if let Some(parent_id) = parent_commit_id.as_ref() { + if let Ok(parent_oid) = git2::Oid::from_bytes(parent_id) { + let _ = pack_builder.insert_commit(parent_oid); + } + } + let mut pack_buf = git2::Buf::new(); + pack_builder.write_buf(&mut pack_buf)?; + let pack_bytes = pack_buf.to_vec(); + + let commit_hex = encode_commit_id(remote_oid.as_bytes()); + let remote_meta = CommitMeta { + commit_id: remote_oid.as_bytes().to_vec(), + parent_commit_id, + message, + author_name, + author_email, + committed_at, + pack_key: format!("git/packs/{}/{}.pack", workspace_id, commit_hex), + file_hash_index: remote_index, + }; + (remote_meta, pack_bytes) + }; + remote_pack = Some((remote_meta, remote_pack_bytes)); + } + + // Fast-forward when there is no local history or the workspace head cleanly trails remote. + // For fresh workspaces with dirty changes, surface conflicts instead of overwriting. + if fast_forward_remote { + if matches!(commit_relation, CommitRelation::NoLocal) + && (!dirty_rows.is_empty() || !remote_conflicts.is_empty()) + { + return Ok(GitPullResultDto { + success: false, + message: "conflicts detected".to_string(), + files_changed: 0, + commit_hash: None, + conflicts: Some(remote_conflicts.clone()), + base_commit: base_commit.clone(), + remote_commit: remote_commit.clone(), + }); + } + // Ensure we have pack data for the remote head regardless of existing metadata. + let (remote_meta, remote_pack_bytes) = if let Some((meta, pack)) = remote_pack.take() { + (meta, Some(pack)) + } else { + let mut pack_builder = repo.packbuilder()?; + pack_builder.insert_commit(remote_oid)?; + // Include parent to avoid missing bases later. + if let Ok(parent_id) = repo.find_commit(remote_oid).and_then(|c| c.parent_id(0)) { + let _ = pack_builder.insert_commit(parent_id); + } + let mut pack_buf = git2::Buf::new(); + pack_builder.write_buf(&mut pack_buf)?; + let pack_bytes = pack_buf.to_vec(); + + let remote_index: HashMap = remote_state + .iter() + .map(|(p, snap)| (p.clone(), snap.hash.clone())) + .collect(); + let commit = repo.find_commit(remote_oid)?; + let committed_at = git_time_to_datetime(commit.time())?; + let message = commit + .message() + .map(|m| m.trim_end_matches('\n').to_string()) + .filter(|m| !m.trim().is_empty()); + let author = commit.author(); + let author_name = author.name().map(|s| s.to_string()); + let author_email = author.email().map(|s| s.to_string()); + let parent_commit_id = if commit.parent_count() > 0 { + Some(commit.parent_id(0)?.as_bytes().to_vec()) + } else { + None + }; + let commit_hex = encode_commit_id(remote_oid.as_bytes()); + let meta = CommitMeta { + commit_id: remote_oid.as_bytes().to_vec(), + parent_commit_id, + message, + author_name, + author_email, + committed_at, + pack_key: format!("git/packs/{}/{}.pack", workspace_id, commit_hex), + file_hash_index: remote_index, + }; + (meta, Some(pack_bytes)) + }; + + if let Some(pack_bytes) = remote_pack_bytes.as_ref() { + self.git_storage + .store_pack(workspace_id, pack_bytes, &remote_meta) + .await?; + } + self.upsert_commit_record(workspace_id, &remote_meta) + .await?; + + let snapshot_keys = self + .store_commit_snapshots(workspace_id, &remote_meta.commit_id, &remote_state) + .await?; + + if let Err(err) = self + .git_storage + .set_latest_commit(workspace_id, Some(&remote_meta)) + .await + { + for key in snapshot_keys.iter().rev() { + let _ = self.git_storage.delete_blob(key).await; + } + return Err(err); + } + + let mut tx = self.pool.begin().await?; + // Ensure repo row still exists and initialized. + let repo_row = + sqlx::query("SELECT initialized FROM git_repository_state WHERE workspace_id = $1") + .bind(workspace_id) + .fetch_optional(&mut *tx) + .await?; + let Some(repo_row) = repo_row else { + tx.rollback().await.ok(); + anyhow::bail!("repository not initialized") + }; + let initialized: bool = repo_row.get("initialized"); + if !initialized { + tx.rollback().await.ok(); + anyhow::bail!("repository not initialized") + } + + sqlx::query( + r#"INSERT INTO git_commits ( + commit_id, + parent_commit_id, + workspace_id, + message, + author_name, + author_email, + committed_at, + pack_key, + file_hash_index + ) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9) + ON CONFLICT (commit_id, workspace_id) DO NOTHING"#, + ) + .bind(remote_meta.commit_id.clone()) + .bind(remote_meta.parent_commit_id.clone()) + .bind(workspace_id) + .bind(remote_meta.message.clone()) + .bind(remote_meta.author_name.clone()) + .bind(remote_meta.author_email.clone()) + .bind(remote_meta.committed_at) + .bind(remote_meta.pack_key.clone()) + .bind(Json(&remote_meta.file_hash_index)) + .execute(&mut *tx) + .await?; + + sqlx::query( + "UPDATE git_repository_state SET updated_at = now() WHERE workspace_id = $1", + ) + .bind(workspace_id) + .execute(&mut *tx) + .await?; + tx.commit().await?; + + let files_changed = self + .apply_state_to_workspace(workspace_id, &remote_state, &previous_index) + .await?; + + // Create any missing documents/attachments from the pulled state before syncing realtime. + self.materialize_documents_from_state(workspace_id, actor_id, &remote_state) + .await?; + self.apply_merged_to_documents(workspace_id, &remote_state) + .await?; + self.clear_dirty(workspace_id).await.map_err(|err| { + error!(workspace_id = %workspace_id, error = %err, "git_pull_clear_dirty_failed"); + err + })?; + + info!( + workspace_id = %workspace_id, + commit = %encode_commit_id(&remote_meta.commit_id), + "git_pull_fast_forward_remote" + ); + + return Ok(GitPullResultDto { + success: true, + message: "fast-forwarded to remote".to_string(), + files_changed, + commit_hash: Some(encode_commit_id(&remote_meta.commit_id)), + conflicts: None, + base_commit: base_commit.clone(), + remote_commit: Some(remote_meta.commit_id.clone()), + }); + } + + // Diverged: merge local into remote (linear, parent = remote) + let Some(local_oid_val) = local_oid else { + anyhow::bail!("no local commit to merge"); + }; + + let (meta, pack_bytes, merged_snapshots, commit_hex) = { + // Build a synthetic "ours" commit from the current workspace state anchored to the local head + // so dirty edits participate in the merge against remote changes. + let synthetic_ours = self.build_synthetic_commit(workspace_id, &repo, local_oid_val)?; + let ours_commit = repo.find_commit(synthetic_ours)?; + let remote_commit_obj = repo.find_commit(remote_oid)?; + let index = repo.merge_commits(&ours_commit, &remote_commit_obj, None)?; + + let conflict_items = collect_conflicts(&repo, &index)?; + if !conflict_items.is_empty() && req.resolutions.is_empty() { + return Ok(GitPullResultDto { + success: false, + message: "conflicts detected".to_string(), + files_changed: 0, + commit_hash: None, + conflicts: Some(conflict_items), + base_commit: base_commit.clone(), + remote_commit: remote_commit.clone(), + }); + } + + // Collect conflict entries for resolution application + let mut conflict_entries: Vec<( + String, + Option>, + Option>, + Option>, + )> = Vec::new(); + { + let mut conflicts_iter = index.conflicts()?; + while let Some(conflict) = conflicts_iter.next() { + let conflict = conflict?; + let path = conflict + .our + .as_ref() + .or(conflict.their.as_ref()) + .or(conflict.ancestor.as_ref()) + .and_then(|e| std::str::from_utf8(&e.path).ok()) + .ok_or_else(|| anyhow!("missing conflict path"))? + .to_string(); + + let to_bytes = + |entry: Option<&git2::IndexEntry>| -> anyhow::Result>> { + if let Some(e) = entry { + let blob = repo.find_blob(e.id)?; + Ok(Some(blob.content().to_vec())) + } else { + Ok(None) + } + }; + + conflict_entries.push(( + path, + to_bytes(conflict.our.as_ref())?, + to_bytes(conflict.their.as_ref())?, + to_bytes(conflict.ancestor.as_ref())?, + )); + } + } + + let resolution_map: std::collections::HashMap< + String, + &crate::application::dto::git::GitPullResolutionDto, + > = req + .resolutions + .iter() + .map(|r| (r.path.clone(), r)) + .collect(); + + // Build merged state from resolved index (stage 0) plus user resolutions. + let mut merged_snapshots: HashMap = HashMap::new(); + for entry in index.iter() { + if index_entry_stage(&entry) != 0 { + continue; + } + let path = index_entry_path(&entry)?; + let blob = repo.find_blob(entry.id)?; + let bytes = blob.content().to_vec(); + let hash = sha256_hex(&bytes); + let is_text = std::str::from_utf8(&bytes).is_ok(); + merged_snapshots.insert( + path, + FileSnapshot { + hash, + data: FileSnapshotData::Inline(bytes), + is_text, + }, + ); + } + + let mut unresolved: Vec = Vec::new(); + + for (path, ours_bytes, theirs_bytes, base_bytes) in conflict_entries { + let resolution = resolution_map.get(&path); + if resolution.is_none() { + let (mut ours_txt, ours_bin) = + as_text_or_binary(path.as_str(), ours_bytes.as_ref()); + let (mut theirs_txt, theirs_bin) = + as_text_or_binary(path.as_str(), theirs_bytes.as_ref()); + let (mut base_txt, base_bin) = + as_text_or_binary(path.as_str(), base_bytes.as_ref()); + let is_binary = ours_bin || theirs_bin || base_bin; + if !is_binary { + ours_txt = strip_front_matter_body(path.as_str(), ours_txt); + theirs_txt = strip_front_matter_body(path.as_str(), theirs_txt); + base_txt = strip_front_matter_body(path.as_str(), base_txt); + } + unresolved.push(GitPullConflictItemDto { + path: path.clone(), + is_binary, + ours: ours_txt, + theirs: theirs_txt, + base: base_txt, + document_id: None, + }); + continue; + } + + let res = *resolution.unwrap(); + let selected_bytes = match res.choice.as_str() { + "ours" => ours_bytes.clone(), + "theirs" => theirs_bytes.clone(), + "custom_text" => { + let content = res + .content + .as_ref() + .ok_or_else(|| anyhow!("custom_text content required"))?; + Some(content.as_bytes().to_vec()) + } + other => anyhow::bail!("unsupported resolution choice {other}"), + } + .unwrap_or_default(); + let hash = sha256_hex(&selected_bytes); + let is_text = std::str::from_utf8(&selected_bytes).is_ok(); + merged_snapshots.insert( + path.clone(), + FileSnapshot { + hash, + data: FileSnapshotData::Inline(selected_bytes), + is_text, + }, + ); + } + + if !unresolved.is_empty() { + return Ok(GitPullResultDto { + success: false, + message: "conflicts detected".to_string(), + files_changed: 0, + commit_hash: None, + conflicts: Some(unresolved), + base_commit: base_commit.clone(), + remote_commit: remote_commit.clone(), + }); + } + + // Build tree from merged snapshots without async work + let mut entry_map: BTreeMap> = BTreeMap::new(); + for (path, snap) in merged_snapshots.iter() { + let bytes = match &snap.data { + FileSnapshotData::Inline(b) => b.clone(), + FileSnapshotData::StoragePath(_) => { + anyhow::bail!("unexpected storage-backed snapshot during pull merge") + } + }; + entry_map.insert(path.clone(), bytes); + } + let tree_oid = build_tree_from_entries(&repo, &entry_map)?; + let tree = repo.find_tree(tree_oid)?; + let sig = signature_from_parts("RefMD", "refmd@example.com", chrono::Utc::now())?; + let base_parent = repo.find_commit(local_oid_val)?; + let remote_parent = repo.find_commit(remote_oid)?; + let parent_refs: [&git2::Commit; 2] = [&base_parent, &remote_parent]; + let commit_oid = repo.commit( + None, + &sig, + &sig, + "Merge remote changes", + &tree, + &parent_refs, + )?; + + let mut file_hash_index: HashMap = HashMap::new(); + for (path, snap) in merged_snapshots.iter() { + file_hash_index.insert(path.clone(), snap.hash.clone()); + } + + let mut pack_builder = repo.packbuilder()?; + pack_builder.insert_commit(commit_oid)?; + // Include both parents to avoid missing bases when applying packs later. + pack_builder.insert_commit(base_parent.id())?; + pack_builder.insert_commit(remote_parent.id())?; + let mut pack_buf = git2::Buf::new(); + pack_builder.write_buf(&mut pack_buf)?; + let pack_bytes = pack_buf.to_vec(); + + let commit_hex = encode_commit_id(commit_oid.as_bytes()); + let meta = CommitMeta { + commit_id: commit_oid.as_bytes().to_vec(), + // Keep workspace history linear: parent is previous workspace head. + parent_commit_id: base_commit.clone(), + message: Some("Merge remote changes".to_string()), + author_name: Some("RefMD".to_string()), + author_email: Some("refmd@example.com".to_string()), + committed_at: chrono::Utc::now(), + pack_key: format!("git/packs/{}/{}.pack", workspace_id, commit_hex), + file_hash_index, + }; + + (meta, pack_bytes, merged_snapshots, commit_hex) + }; + + // Persist remote parent if we created it above. + if let Some((remote_meta, remote_pack_bytes)) = remote_pack.take() { + self.git_storage + .store_pack(workspace_id, &remote_pack_bytes, &remote_meta) + .await?; + self.upsert_commit_record(workspace_id, &remote_meta) + .await?; + } + + let snapshot_keys = self + .store_commit_snapshots(workspace_id, &meta.commit_id, &merged_snapshots) + .await?; + + if let Err(err) = self + .git_storage + .store_pack(workspace_id, &pack_bytes, &meta) + .await + { + for key in snapshot_keys.iter().rev() { + let _ = self.git_storage.delete_blob(key).await; + } + return Err(err); + } + + if let Err(err) = self + .git_storage + .set_latest_commit(workspace_id, Some(&meta)) + .await + { + let _ = self + .git_storage + .delete_pack(workspace_id, &meta.commit_id) + .await; + for key in snapshot_keys.iter().rev() { + let _ = self.git_storage.delete_blob(key).await; + } + return Err(err); + } + let mut tx = self.pool.begin().await?; - // Recheck repository state exists before writing. - let repo_row2 = - sqlx::query("SELECT initialized FROM git_repository_state WHERE workspace_id = $1") - .bind(workspace_id) - .fetch_optional(&mut *tx) - .await?; - let Some(repo_row2) = repo_row2 else { - tx.rollback().await.ok(); - anyhow::bail!("repository not initialized") - }; - let initialized2: bool = repo_row2.get("initialized"); - if !initialized2 { - tx.rollback().await.ok(); - anyhow::bail!("repository not initialized") - } - sqlx::query( r#"INSERT INTO git_commits ( commit_id, @@ -1787,7 +3682,7 @@ impl GitWorkspacePort for GitWorkspaceService { committed_at, pack_key, file_hash_index - ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)"#, + ) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9)"#, ) .bind(meta.commit_id.clone()) .bind(meta.parent_commit_id.clone()) @@ -1805,160 +3700,166 @@ impl GitWorkspacePort for GitWorkspaceService { .bind(workspace_id) .execute(&mut *tx) .await?; + tx.commit().await?; - // Only store snapshots for changed text files (incremental), or all in initial full scan - let snapshot_keys = if use_full_scan { - // full state snapshot - let current = self.collect_current_state(workspace_id).await?; - match self - .store_commit_snapshots(workspace_id, &meta.commit_id, ¤t) - .await - { - Ok(keys) => keys, - Err(err) => { - tx.rollback().await.ok(); - return Err(err); - } - } - } else { - match self - .store_commit_snapshots(workspace_id, &meta.commit_id, &changed_text_snapshots) - .await - { - Ok(keys) => keys, - Err(err) => { - tx.rollback().await.ok(); - return Err(err); - } - } - }; - - if let Err(err) = self - .git_storage - .store_pack(workspace_id, &pack_bytes, &meta) - .await - { - for key in snapshot_keys.iter().rev() { - let _ = self.git_storage.delete_blob(key).await; - } - tx.rollback().await.ok(); - return Err(err); - } - - if let Err(err) = self - .git_storage - .set_latest_commit(workspace_id, Some(&meta)) - .await - { - let _ = self - .git_storage - .delete_pack(workspace_id, &meta.commit_id) - .await; - for key in snapshot_keys.iter().rev() { - let _ = self.git_storage.delete_blob(key).await; - } - tx.rollback().await.ok(); - return Err(err); - } + let files_changed = self + .apply_state_to_workspace(workspace_id, &merged_snapshots, &previous_index) + .await?; - if let Err(err) = tx.commit().await { - let _ = self - .git_storage - .delete_pack(workspace_id, &meta.commit_id) - .await; - for key in snapshot_keys.iter().rev() { - let _ = self.git_storage.delete_blob(key).await; - } - let _ = self - .git_storage - .set_latest_commit(workspace_id, latest_meta.as_ref()) - .await; - return Err(err.into()); - } + // Create any missing documents/attachments from the merged state before syncing realtime. + self.materialize_documents_from_state(workspace_id, actor_id, &merged_snapshots) + .await?; + // Apply merged markdown back into realtime/doc storage immediately. + self.apply_merged_to_documents(workspace_id, &merged_snapshots) + .await?; - // Best-effort clear of processed dirty entries - let _ = self.clear_dirty(workspace_id).await; - let outcome_message = if pushed { - "sync completed".to_string() - } else if skip_push { - "sync completed (push skipped)".to_string() - } else { - "commit created (push failed)".to_string() - }; + self.clear_dirty(workspace_id).await.map_err(|err| { + error!(workspace_id = %workspace_id, error = %err, "git_pull_merge_clear_dirty_failed"); + err + })?; - Ok(GitSyncOutcome { - files_changed: files_changed_for_response, + Ok(GitPullResultDto { + success: true, + message: "remote changes merged".to_string(), + files_changed, commit_hash: Some(commit_hex), - pushed, - message: outcome_message, + conflicts: None, + base_commit, + remote_commit, }) } - async fn check_remote( + async fn persist_pack_chain( &self, workspace_id: Uuid, - cfg: &UserGitCfg, - ) -> anyhow::Result { - if cfg.repository_url.is_empty() { - return Ok(GitRemoteCheckDto { - ok: true, - message: "remote not configured".to_string(), - reason: Some("no_remote".to_string()), - }); - } - let branch = cfg.branch_name.clone(); - let temp_dir = TempDirBuilder::new() - .prefix("git-check-") - .tempdir() - .map_err(|e| anyhow!(e))?; - let repo = Repository::init_bare(temp_dir.path())?; - let result = match fetch_remote_head(&repo, cfg, &branch) { - Ok(Some(_)) => GitRemoteCheckDto { - ok: true, - message: "remote reachable".to_string(), - reason: None, - }, - Ok(None) => GitRemoteCheckDto { - ok: false, - message: format!("branch '{branch}' not found on remote"), - reason: Some("branch_missing".to_string()), - }, - Err(err) => { - let lower = err.to_string().to_lowercase(); - let (reason, msg) = if lower.contains("git_http_auth_redirect") { - ( - Some("auth_required".to_string()), - "remote requires authentication or SSO approval".to_string(), - ) - } else if lower.contains("git_http_not_found") || lower.contains("status code: 404") - { - ( - Some("repo_not_found".to_string()), - "repository URL or branch not found".to_string(), - ) + until: Option<&[u8]>, + ) -> anyhow::Result)>> { + // Attempt to rebuild pack chain from stored snapshots if packs are missing or corrupted. + async fn rebuild_from_snapshots( + svc: &GitWorkspaceService, + workspace_id: Uuid, + until: Option<&[u8]>, + ) -> anyhow::Result)>> { + // Collect commit metas from oldest to newest + let mut chain: Vec = Vec::new(); + let mut cursor = match until { + Some(id) => svc.commit_meta_by_id(workspace_id, id).await?, + None => svc.latest_commit_meta(workspace_id).await?, + }; + while let Some(meta) = cursor { + chain.push(meta.clone()); + if let Some(parent) = meta.parent_commit_id.as_ref() { + cursor = svc.commit_meta_by_id(workspace_id, parent).await?; } else { - (None, err.to_string()) - }; - GitRemoteCheckDto { - ok: false, - message: msg, - reason, + break; } } - }; - drop(repo); - let _ = temp_dir.close(); - info!(workspace_id = %workspace_id, ok = %result.ok, reason = ?result.reason, "git_remote_check_completed"); - Ok(result) - } -} + if chain.is_empty() { + return Ok(None); + } + chain.reverse(); + + // Preload snapshots async + let mut prepared: Vec<(CommitMeta, Vec<(String, Vec)>)> = Vec::new(); + for meta in chain.iter() { + let mut entries: Vec<(String, Vec)> = Vec::new(); + for path in meta.file_hash_index.keys() { + let Some(bytes) = svc + .load_file_snapshot(workspace_id, meta.commit_id.as_slice(), path) + .await? + else { + anyhow::bail!( + "missing snapshot blob for {} at commit {}", + path, + encode_commit_id(&meta.commit_id) + ); + }; + entries.push((path.clone(), bytes)); + } + prepared.push((meta.clone(), entries)); + } + + // Build packs synchronously to avoid Send issues with git2 types + let (temp_dir, pack_paths) = tokio::task::block_in_place(|| -> anyhow::Result<_> { + let temp_dir = tempfile::tempdir()?; + let repo = Repository::init_bare(temp_dir.path())?; + let mut built_commits: HashMap, git2::Oid> = HashMap::new(); + let mut pack_paths: Vec = Vec::new(); + + for (meta, entries) in prepared.into_iter() { + let mut builder = repo.treebuilder(None)?; + for (path, bytes) in entries.iter() { + let blob_oid = repo.blob(bytes)?; + builder.insert(path, blob_oid, FileMode::Blob.into())?; + } + let tree_oid = builder.write()?; + let tree = repo.find_tree(tree_oid)?; + + let sig = signature_from_parts( + meta.author_name.as_deref().unwrap_or("RefMD"), + meta.author_email.as_deref().unwrap_or("refmd@example.com"), + meta.committed_at, + )?; + let mut parents = Vec::new(); + if let Some(parent) = meta.parent_commit_id.as_ref() { + if let Some(existing) = built_commits.get(parent) { + parents.push(repo.find_commit(*existing)?); + } + } + let parent_refs: Vec<&Commit> = parents.iter().collect(); + let commit_oid = repo.commit( + None, + &sig, + &sig, + meta.message + .as_deref() + .unwrap_or("Recovered commit from snapshots"), + &tree, + &parent_refs, + )?; + if commit_oid.as_bytes() != meta.commit_id.as_slice() { + anyhow::bail!( + "reconstructed commit id mismatch for {}", + encode_commit_id(&meta.commit_id) + ); + } + built_commits.insert(meta.commit_id.clone(), commit_oid); + + let mut pack_builder = repo.packbuilder()?; + pack_builder.insert_commit(commit_oid)?; + for p in parents.iter() { + pack_builder.insert_commit(p.id())?; + } + let mut pack_buf = git2::Buf::new(); + pack_builder.write_buf(&mut pack_buf)?; + let pack_bytes = pack_buf.to_vec(); + + let pack_path = temp_dir + .path() + .join(format!("{:08}.pack", pack_paths.len())); + std::fs::write(&pack_path, &pack_bytes)?; + pack_paths.push(pack_path); + } + + Ok((temp_dir, pack_paths)) + })?; + + // Persist rebuilt packs and metas back to storage + for (idx, meta) in chain.iter().enumerate() { + let pack_bytes = std::fs::read(&pack_paths[idx])?; + svc.git_storage + .store_pack(workspace_id, &pack_bytes, meta) + .await?; + svc.upsert_commit_record(workspace_id, meta).await?; + let _ = svc + .git_storage + .set_latest_commit(workspace_id, Some(meta)) + .await; + } + + Ok(Some((temp_dir, pack_paths))) + } -impl GitWorkspaceService { - async fn persist_pack_chain( - &self, - workspace_id: Uuid, - until: Option<&[u8]>, - ) -> anyhow::Result)>> { let mut attempts = 0; loop { match self.git_storage.load_pack_chain(workspace_id, until).await { @@ -1980,6 +3881,13 @@ impl GitWorkspaceService { } } Err(err) => { + let err_str = err.to_string(); + let is_missing_objects = err_str.to_lowercase().contains("missing") + && err_str.to_lowercase().contains("object"); + if let Some(rebuilt) = rebuild_from_snapshots(self, workspace_id, until).await? + { + return Ok(Some(rebuilt)); + } if attempts == 0 { if let Some(commit_hex) = missing_metadata_commit(&err) { match self @@ -2000,6 +3908,21 @@ impl GitWorkspaceService { } } } + // If pack is missing objects, fall back by resetting git storage pointer and DB history. + if is_missing_objects { + warn!( + workspace_id = %workspace_id, + error = %err, + "git_pack_missing_objects_detected_resetting_history" + ); + // Drop storage latest pointer and DB commits for this workspace. + let _ = self.git_storage.set_latest_commit(workspace_id, None).await; + let _ = sqlx::query("DELETE FROM git_commits WHERE workspace_id = $1") + .bind(workspace_id) + .execute(&self.pool) + .await; + return Ok(None); + } } return Err(err); } @@ -2168,6 +4091,94 @@ fn apply_pack_to_repo(repo: &Repository, pack: &[u8]) -> anyhow::Result<()> { Ok(()) } +fn read_first_pack(repo_path: &Path) -> anyhow::Result>> { + let pack_dir = repo_path.join("objects").join("pack"); + if !pack_dir.exists() { + return Ok(None); + } + let mut entries: Vec<_> = std::fs::read_dir(&pack_dir)? + .filter_map(|e| e.ok()) + .filter(|e| { + e.path() + .extension() + .map(|ext| ext == "pack") + .unwrap_or(false) + }) + .collect(); + entries.sort_by_key(|e| e.file_name()); + if let Some(entry) = entries.first() { + let bytes = std::fs::read(entry.path())?; + return Ok(Some(bytes)); + } + Ok(None) +} + +fn find_front_matter_end(s: &str) -> Option<(usize, usize)> { + let bytes = s.as_bytes(); + let mut idx = 0; + while idx < bytes.len() { + if bytes[idx] == b'\n' { + let after_newline = &s[idx + 1..]; + if after_newline.starts_with("---") { + let mut body_start = idx + 1 + 3; + let mut remainder = &s[body_start..]; + // Skip trailing newlines after the closing delimiter to mirror ingest. + while remainder.starts_with("\r\n") || remainder.starts_with('\n') { + if remainder.starts_with("\r\n") { + body_start += 2; + remainder = &s[body_start..]; + } else { + body_start += 1; + remainder = &s[body_start..]; + } + } + return Some((idx, body_start)); + } + } + idx += 1; + } + None +} + +fn split_front_matter(input: &str) -> Option<(&str, &str)> { + let Some(after_open) = input + .strip_prefix("---\r\n") + .or_else(|| input.strip_prefix("---\n")) + else { + return None; + }; + if let Some((front_len, body_start)) = find_front_matter_end(after_open) { + let front = &after_open[..front_len]; + let body = &after_open[body_start..]; + return Some((front, body)); + } + None +} + +fn strip_front_matter_body(path: &str, text: Option) -> Option { + let Some(txt) = text else { + return None; + }; + let lower = path.to_ascii_lowercase(); + let is_markdown = lower.ends_with(".md") || lower.ends_with(".markdown"); + if !is_markdown { + return Some(txt); + } + if let Some((_, body)) = split_front_matter(txt.as_str()) { + return Some(body.to_string()); + } + Some(txt) +} + +fn extract_markdown_body(bytes: &[u8]) -> Option { + let text = std::str::from_utf8(bytes).ok()?; + let trimmed = text.trim_start_matches('\u{feff}'); + if let Some((_, body)) = split_front_matter(trimmed) { + return Some(body.to_string()); + } + Some(trimmed.to_string()) +} + fn missing_metadata_commit(err: &anyhow::Error) -> Option { let needle = "metadata not found for commit "; for cause in err.chain() { @@ -2195,6 +4206,105 @@ fn apply_pack_files(repo: &Repository, pack_paths: &[PathBuf]) -> anyhow::Result Ok(()) } +fn collect_conflicts( + repo: &Repository, + index: &git2::Index, +) -> anyhow::Result> { + let mut out = Vec::new(); + let mut conflicts = index.conflicts()?; + while let Some(conflict) = conflicts.next() { + let conflict = conflict?; + let path = conflict + .our + .as_ref() + .or(conflict.their.as_ref()) + .or(conflict.ancestor.as_ref()) + .and_then(|e| std::str::from_utf8(&e.path).ok()) + .unwrap_or("") + .to_string(); + + let to_bytes = |entry: Option<&git2::IndexEntry>| -> anyhow::Result>> { + if let Some(e) = entry { + let blob = repo.find_blob(e.id)?; + Ok(Some(blob.content().to_vec())) + } else { + Ok(None) + } + }; + + let ours_bytes = to_bytes(conflict.our.as_ref())?; + let theirs_bytes = to_bytes(conflict.their.as_ref())?; + let base_bytes = to_bytes(conflict.ancestor.as_ref())?; + + let (mut ours, ours_bin) = as_text_or_binary(path.as_str(), ours_bytes.as_ref()); + let (mut theirs, theirs_bin) = as_text_or_binary(path.as_str(), theirs_bytes.as_ref()); + let (mut base, base_bin) = as_text_or_binary(path.as_str(), base_bytes.as_ref()); + let is_binary = ours_bin || theirs_bin || base_bin; + if !is_binary { + ours = strip_front_matter_body(path.as_str(), ours); + theirs = strip_front_matter_body(path.as_str(), theirs); + base = strip_front_matter_body(path.as_str(), base); + } + + out.push(GitPullConflictItemDto { + path, + is_binary, + ours, + theirs, + base, + document_id: None, + }); + } + Ok(out) +} + +fn index_entry_path(entry: &git2::IndexEntry) -> anyhow::Result { + let raw = &entry.path; + if raw.is_empty() { + anyhow::bail!("empty index entry path"); + } + if let Ok(cstr) = std::ffi::CStr::from_bytes_with_nul(raw) { + Ok(cstr + .to_str() + .unwrap_or_default() + .trim_end_matches('\0') + .to_string()) + } else { + Ok(String::from_utf8_lossy(raw) + .trim_end_matches('\0') + .to_string()) + } +} + +fn index_entry_stage(entry: &git2::IndexEntry) -> i32 { + ((entry.flags as u32 >> 12) & 0b11) as i32 +} + +fn as_text_or_binary(path: &str, data: Option<&Vec>) -> (Option, bool) { + let Some(bytes) = data else { + return (None, false); + }; + match std::str::from_utf8(bytes) { + Ok(s) => (Some(s.to_string()), false), + Err(_) => { + let lower = path.to_ascii_lowercase(); + let looks_text = lower.ends_with(".md") + || lower.ends_with(".markdown") + || lower.ends_with(".txt") + || lower.ends_with(".json") + || lower.ends_with(".yaml") + || lower.ends_with(".yml") + || lower.ends_with(".toml") + || lower.ends_with(".ini"); + if looks_text { + let lossy = String::from_utf8_lossy(bytes).to_string(); + return (Some(lossy), false); + } + (None, true) + } + } +} + fn extract_host(url: &str) -> Option { let s = url.trim(); let s = s @@ -2510,17 +4620,14 @@ fn normalize_repo_path(path: String) -> String { if trimmed.is_empty() { String::new() } else { - trimmed.replace('\\', "/") + trimmed + .replace('\\', "/") + .trim_start_matches("./") + .trim_start_matches('/') + .to_string() } } -fn sha256_hex(bytes: &[u8]) -> String { - use sha2::Digest; - let mut hasher = sha2::Sha256::new(); - hasher.update(bytes); - format!("{:x}", hasher.finalize()) -} - fn blob_key(workspace_id: Uuid, commit_id: &[u8], path: &str) -> BlobKey { let encoded_path = urlencoding::encode(path); let commit_hex = encode_commit_id(commit_id); diff --git a/api/src/infrastructure/storage/core.rs b/api/src/infrastructure/storage/core.rs index 18b24119..6b53d990 100644 --- a/api/src/infrastructure/storage/core.rs +++ b/api/src/infrastructure/storage/core.rs @@ -193,6 +193,9 @@ pub async fn mark_dirty_upsert_abs_path( is_text: bool, content_hash: Option<&str>, ) -> anyhow::Result<()> { + if crate::infrastructure::storage::dirty_tracking_suppressed() { + return Ok(()); + } let rel = relative_from_uploads(uploads_root, abs_path).replace('\\', "/"); if let Some((workspace_id, repo_path)) = split_owner_and_repo_path(&rel) { if !repo_path.is_empty() { @@ -210,6 +213,9 @@ pub async fn mark_dirty_upsert_relative( is_text: bool, content_hash: Option<&str>, ) -> anyhow::Result<()> { + if crate::infrastructure::storage::dirty_tracking_suppressed() { + return Ok(()); + } let rel = relative.trim_start_matches('/'); if let Some((workspace_id, repo_path)) = split_owner_and_repo_path(rel) { if !repo_path.is_empty() { @@ -222,6 +228,9 @@ pub async fn mark_dirty_upsert_relative( } pub async fn mark_dirty_delete_relative(pool: &PgPool, relative: &str) -> anyhow::Result<()> { + if crate::infrastructure::storage::dirty_tracking_suppressed() { + return Ok(()); + } let rel = relative.trim_start_matches('/'); if let Some((workspace_id, repo_path)) = split_owner_and_repo_path(rel) { if !repo_path.is_empty() { diff --git a/api/src/infrastructure/storage/dirty.rs b/api/src/infrastructure/storage/dirty.rs new file mode 100644 index 00000000..5953f936 --- /dev/null +++ b/api/src/infrastructure/storage/dirty.rs @@ -0,0 +1,44 @@ +use std::sync::atomic::{AtomicUsize, Ordering}; +use tokio::task_local; + +task_local! { + static SUPPRESS_GIT_DIRTY: bool; +} + +// Global counter to suppress dirty tracking across tasks (e.g., filesystem watchers triggered by bulk writes). +static GLOBAL_SUPPRESS_COUNT: AtomicUsize = AtomicUsize::new(0); + +/// Guard that decrements the global suppression counter when dropped. +pub struct GlobalSuppressGuard; + +impl Drop for GlobalSuppressGuard { + fn drop(&mut self) { + GLOBAL_SUPPRESS_COUNT + .fetch_update(Ordering::SeqCst, Ordering::SeqCst, |v| { + Some(v.saturating_sub(1)) + }) + .ok(); + } +} + +/// Returns true when dirty tracking should be skipped for the current task. +pub fn dirty_tracking_suppressed() -> bool { + if GLOBAL_SUPPRESS_COUNT.load(Ordering::SeqCst) > 0 { + return true; + } + SUPPRESS_GIT_DIRTY.try_with(|v| *v).unwrap_or(false) +} + +/// Run a future with git dirty tracking suppressed for storage writes. +pub async fn suppress_git_dirty(fut: F) -> T +where + F: std::future::Future, +{ + SUPPRESS_GIT_DIRTY.scope(true, fut).await +} + +/// Increment the global suppression counter for the lifetime of the returned guard. +pub fn suppress_git_dirty_global() -> GlobalSuppressGuard { + GLOBAL_SUPPRESS_COUNT.fetch_add(1, Ordering::SeqCst); + GlobalSuppressGuard +} diff --git a/api/src/infrastructure/storage/fs_ingest_watcher.rs b/api/src/infrastructure/storage/fs_ingest_watcher.rs index 6bccd0d5..d560a38e 100644 --- a/api/src/infrastructure/storage/fs_ingest_watcher.rs +++ b/api/src/infrastructure/storage/fs_ingest_watcher.rs @@ -5,13 +5,13 @@ use std::time::Duration; use notify::event::{EventKind, ModifyKind, RenameMode}; use notify::{Config, Event, RecommendedWatcher, RecursiveMode, Watcher}; use serde_json::{Map, Value}; -use sha2::{Digest, Sha256}; use tokio::sync::mpsc::{self, UnboundedSender}; use tracing::{debug, error, warn}; use uuid::Uuid; use crate::application::ports::storage_ingest_queue::{StorageIngestKind, StorageIngestQueue}; use crate::application::services::storage_ingest::normalize_repo_path; +use crate::application::utils::hash::sha256_hex; use crate::domain::workspaces::permissions::PermissionSet; pub struct FsIngestWatcher { @@ -181,13 +181,7 @@ impl FsIngestWatcher { ) -> (Option, Option) { match tokio::fs::read(path).await { Ok(bytes) => { - let mut hasher = Sha256::new(); - hasher.update(&bytes); - let hash = hasher - .finalize() - .iter() - .map(|b| format!("{b:02x}")) - .collect::(); + let hash = sha256_hex(&bytes); let payload = serde_json::json!({ "file_kind": file_kind(repo_path), "is_text": repo_path.ends_with(".md"), diff --git a/api/src/infrastructure/storage/mod.rs b/api/src/infrastructure/storage/mod.rs index 5390d5b5..dec9af49 100644 --- a/api/src/infrastructure/storage/mod.rs +++ b/api/src/infrastructure/storage/mod.rs @@ -1,4 +1,5 @@ mod core; +mod dirty; mod fs_ingest_watcher; mod gitignore_port_impl; mod ingest_queue; @@ -11,6 +12,7 @@ mod s3_port_impl; mod storage_port_impl; mod worker; pub use core::*; +pub use dirty::*; pub use fs_ingest_watcher::FsIngestWatcher; pub use ingest_queue::PgStorageIngestQueue; pub use ingest_worker::{LoggingStorageIngestHandler, StorageIngestWorker}; diff --git a/api/src/infrastructure/storage/s3_port_impl.rs b/api/src/infrastructure/storage/s3_port_impl.rs index 8a6c7d31..88ccd288 100644 --- a/api/src/infrastructure/storage/s3_port_impl.rs +++ b/api/src/infrastructure/storage/s3_port_impl.rs @@ -12,13 +12,13 @@ use aws_sdk_s3::operation::head_bucket::HeadBucketError; use aws_sdk_s3::primitives::ByteStream; use aws_sdk_s3::types::{Delete, ObjectIdentifier}; use aws_sdk_s3::{Client, error::SdkError}; -use sha2::{Digest, Sha256}; use tokio::io::AsyncReadExt; use uuid::Uuid; use crate::application::ports::storage_port::{ StorageProjectionPort, StorageResolverPort, StoredAttachment, }; +use crate::application::utils::hash::sha256_hex; use crate::infrastructure::db::PgPool; #[derive(Clone, Debug)] @@ -552,13 +552,7 @@ impl StorageResolverPort for S3StoragePort { let key = self.relative_to_key(&relative); self.put_object(&key, bytes).await?; let size = bytes.len() as i64; - let mut hasher = Sha256::new(); - hasher.update(bytes); - let digest = hasher.finalize(); - let hash = digest - .iter() - .map(|b| format!("{b:02x}")) - .collect::(); + let hash = sha256_hex(bytes); Ok(StoredAttachment { filename: target .file_name() diff --git a/api/src/infrastructure/storage/storage_port_impl.rs b/api/src/infrastructure/storage/storage_port_impl.rs index 09d25295..6f773201 100644 --- a/api/src/infrastructure/storage/storage_port_impl.rs +++ b/api/src/infrastructure/storage/storage_port_impl.rs @@ -1,11 +1,10 @@ -use std::fmt::Write; use std::path::{Path, PathBuf}; use uuid::Uuid; use crate::application::ports::storage_port::{ StorageProjectionPort, StorageResolverPort, StoredAttachment, }; -use sha2::{Digest, Sha256}; +use crate::application::utils::hash::sha256_hex; pub struct FsStoragePort { pub pool: crate::infrastructure::db::PgPool, @@ -161,6 +160,21 @@ impl StorageResolverPort for FsStoragePort { if let Some(parent) = abs_path.parent() { tokio::fs::create_dir_all(parent).await?; } + // Short-circuit when content is unchanged to avoid unnecessary dirty tracking. + let new_hash = sha256_hex(data); + if tokio::fs::try_exists(abs_path).await.unwrap_or(false) { + match tokio::fs::read(abs_path).await { + Ok(existing) => { + let old_hex = sha256_hex(&existing); + if old_hex == new_hash { + // No-op write; do not mark dirty. + return Ok(()); + } + } + Err(err) if err.kind() == std::io::ErrorKind::NotFound => {} + Err(err) => return Err(err.into()), + } + } tokio::fs::write(abs_path, data).await?; // Mark dirty (best-effort) let is_text = abs_path @@ -168,19 +182,12 @@ impl StorageResolverPort for FsStoragePort { .and_then(|e| e.to_str()) .map(|e| e.eq_ignore_ascii_case("md")) .unwrap_or(false); - let mut hasher = Sha256::new(); - hasher.update(data); - let digest = hasher.finalize(); - let content_hash = digest - .iter() - .map(|b| format!("{b:02x}")) - .collect::(); let _ = crate::infrastructure::storage::mark_dirty_upsert_abs_path( &self.pool, self.uploads_root.as_path(), abs_path, is_text, - Some(&content_hash), + Some(&new_hash), ) .await; Ok(()) @@ -253,13 +260,7 @@ impl StorageResolverPort for FsStoragePort { .replace('\\', "/"); let size = bytes.len() as i64; - let mut hasher = Sha256::new(); - hasher.update(bytes); - let digest = hasher.finalize(); - let mut content_hash = String::with_capacity(64); - for byte in digest { - let _ = write!(&mut content_hash, "{:02x}", byte); - } + let content_hash = sha256_hex(bytes); Ok(StoredAttachment { filename: safe, diff --git a/api/src/infrastructure/storage/worker.rs b/api/src/infrastructure/storage/worker.rs index 6ffdf512..f02ed6e9 100644 --- a/api/src/infrastructure/storage/worker.rs +++ b/api/src/infrastructure/storage/worker.rs @@ -22,6 +22,7 @@ use crate::application::services::workspaces::permission_snapshot::permission_se use crate::domain::workspaces::permissions::{ PERM_DOC_DELETE, PERM_FILE_DELETE, PERM_FOLDER_DELETE, PermissionSet, }; +use crate::infrastructure::storage::suppress_git_dirty; pub struct StorageProjectionWorker { jobs: Arc, @@ -109,47 +110,50 @@ impl StorageProjectionWorker { async move { let delete_metadata = parse_delete_job_metadata(job.reason.as_ref()); - let result = match job.job_type { - StorageProjectionJobKind::DocSync => { - let doc_id = job - .doc_id - .ok_or_else(|| anyhow::anyhow!("doc_id_required"))?; - let res = self.handle_doc_sync(doc_id).await; - if res.is_ok() { - self.emit_projection_event(doc_id, &job, "succeeded", None) - .await; + let result = suppress_git_dirty(async { + match job.job_type { + StorageProjectionJobKind::DocSync => { + let doc_id = job + .doc_id + .ok_or_else(|| anyhow::anyhow!("doc_id_required"))?; + let res = self.handle_doc_sync(doc_id).await; + if res.is_ok() { + self.emit_projection_event(doc_id, &job, "succeeded", None) + .await; + } + res } - res - } - StorageProjectionJobKind::FolderSync => { - self.handle_folder_sync( - job.folder_id - .ok_or_else(|| anyhow::anyhow!("folder_id_required"))?, - ) - .await - } - StorageProjectionJobKind::DeleteDoc => { - let doc_id = job - .doc_id - .ok_or_else(|| anyhow::anyhow!("doc_id_required"))?; - let res = self - .handle_delete_doc(doc_id, delete_metadata.as_ref()) - .await; - if res.is_ok() { - self.emit_projection_event(doc_id, &job, "succeeded", None) + StorageProjectionJobKind::FolderSync => { + self.handle_folder_sync( + job.folder_id + .ok_or_else(|| anyhow::anyhow!("folder_id_required"))?, + ) + .await + } + StorageProjectionJobKind::DeleteDoc => { + let doc_id = job + .doc_id + .ok_or_else(|| anyhow::anyhow!("doc_id_required"))?; + let res = self + .handle_delete_doc(doc_id, delete_metadata.as_ref()) .await; + if res.is_ok() { + self.emit_projection_event(doc_id, &job, "succeeded", None) + .await; + } + res + } + StorageProjectionJobKind::DeleteFolder => { + self.handle_delete_folder( + job.folder_id + .ok_or_else(|| anyhow::anyhow!("folder_id_required"))?, + delete_metadata.as_ref(), + ) + .await } - res - } - StorageProjectionJobKind::DeleteFolder => { - self.handle_delete_folder( - job.folder_id - .ok_or_else(|| anyhow::anyhow!("folder_id_required"))?, - delete_metadata.as_ref(), - ) - .await } - }; + }) + .await; match result { Ok(()) => { diff --git a/api/src/main.rs b/api/src/main.rs index 81819b37..e5b1e539 100644 --- a/api/src/main.rs +++ b/api/src/main.rs @@ -148,6 +148,11 @@ const SESSION_CLEANUP_BATCH_SIZE: i64 = 500; api::presentation::http::git::get_working_diff, api::presentation::http::git::get_commit_diff, api::presentation::http::git::sync_now, + api::presentation::http::git::import_repository, + api::presentation::http::git::start_pull_session, + api::presentation::http::git::get_pull_session, + api::presentation::http::git::resolve_pull_session, + api::presentation::http::git::finalize_pull_session, api::presentation::http::git::init_repository, api::presentation::http::git::deinit_repository, api::presentation::http::git::ignore_document, @@ -556,6 +561,11 @@ async fn main() -> anyhow::Result<()> { cfg.encryption_key.clone(), ), ); + let git_pull_sessions = Arc::new( + api::infrastructure::db::repositories::git_pull_session_repository_sqlx::GitPullSessionRepositorySqlx::new( + pool.clone(), + ), + ); let auto_archive_interval = Duration::from_secs(cfg.snapshot_archive_interval_secs); let mut local_hub: Option = None; let (realtime_engine, snapshot_service_arc): ( @@ -698,6 +708,8 @@ async fn main() -> anyhow::Result<()> { git_storage.clone(), storage_resolver.clone(), snapshot_service_arc.clone(), + realtime_engine.clone(), + document_repo.clone(), )?, ); let git_service = Arc::new(GitService::new( @@ -707,6 +719,7 @@ async fn main() -> anyhow::Result<()> { document_repo.clone(), gitignore_port.clone(), git_workspace.clone(), + git_pull_sessions.clone(), )); if cfg.git_rebuild_enabled { let rebuild_service = Arc::new(GitRebuildService::new( diff --git a/api/src/presentation/http/git.rs b/api/src/presentation/http/git.rs index 386c39ac..3782ad68 100644 --- a/api/src/presentation/http/git.rs +++ b/api/src/presentation/http/git.rs @@ -11,10 +11,12 @@ use crate::presentation::http::auth::{Bearer, validate_bearer}; // Config is no longer needed directly here use crate::application::dto::diff::TextDiffResult; use crate::application::dto::git::{ - GitChangeItem as GitChangeDto, GitCommitInfo, GitConfigDto, GitStatusDto, GitSyncRequestDto, - GitignoreUpdateDto, UpsertGitConfigInput, + GitChangeItem as GitChangeDto, GitCommitInfo, GitConfigDto, GitPullRequestDto, + GitPullResolutionDto, GitPullSessionDto, GitStatusDto, GitSyncRequestDto, GitignoreUpdateDto, + UpsertGitConfigInput, }; use crate::application::services::errors::ServiceError; +use crate::application::services::git::FinalizePullSessionResult; use crate::domain::workspaces::permissions::{PERM_GIT_CONFIGURE, PERM_GIT_INIT, PERM_GIT_SYNC}; use crate::presentation::context::AppContext; use crate::presentation::http::workspace_scope; @@ -37,6 +39,15 @@ pub fn routes(ctx: AppContext) -> Router { .route("/git/diff/working", get(get_working_diff)) .route("/git/diff/commits/:from/:to", get(get_commit_diff)) .route("/git/sync", post(sync_now)) + .route("/git/import", post(import_repository)) + .route("/git/pull", post(pull_repository)) + .route("/git/pull/start", post(start_pull_session)) + .route("/git/pull/session/:id", get(get_pull_session)) + .route("/git/pull/session/:id/resolve", post(resolve_pull_session)) + .route( + "/git/pull/session/:id/finalize", + post(finalize_pull_session), + ) .route("/git/init", post(init_repository)) .route("/git/deinit", post(deinit_repository)) .route("/git/ignore/doc/:id", post(ignore_document)) @@ -151,6 +162,90 @@ pub struct UpdateGitConfigRequest { pub auto_sync: Option, } +#[derive(Debug, Serialize, Deserialize, ToSchema, Clone)] +pub struct GitPullResolution { + pub path: String, + pub choice: String, + pub content: Option, +} + +#[derive(Debug, Serialize, Deserialize, ToSchema)] +pub struct GitPullRequest { + pub resolutions: Option>, +} + +#[derive(Debug, Serialize, Deserialize, ToSchema, Clone)] +pub struct GitPullConflictItem { + pub path: String, + pub is_binary: bool, + pub ours: Option, + pub theirs: Option, + pub base: Option, + pub document_id: Option, +} + +impl From for GitPullConflictItem { + fn from(value: crate::application::dto::git::GitPullConflictItemDto) -> Self { + Self { + path: value.path, + is_binary: value.is_binary, + ours: value.ours, + theirs: value.theirs, + base: value.base, + document_id: value.document_id, + } + } +} + +#[derive(Debug, Serialize, Deserialize, ToSchema, Clone)] +pub struct GitPullResponse { + pub success: bool, + pub message: String, + pub files_changed: i32, + pub commit_hash: Option, + pub conflicts: Option>, + pub git_status: Option, +} + +#[derive(Debug, Serialize, Deserialize, ToSchema, Clone)] +pub struct GitImportResponse { + pub success: bool, + pub message: String, + pub files_changed: i32, + pub commit_hash: Option, + pub docs_created: i32, + pub attachments_created: i32, +} + +#[derive(Debug, Serialize, Deserialize, ToSchema, Clone)] +pub struct GitPullSessionResponse { + pub session_id: uuid::Uuid, + pub status: String, + pub conflicts: Vec, + pub resolutions: Vec, + pub message: Option, +} + +impl From for GitPullSessionResponse { + fn from(value: GitPullSessionDto) -> Self { + Self { + session_id: value.id, + status: value.status, + conflicts: value.conflicts.into_iter().map(Into::into).collect(), + resolutions: value + .resolutions + .into_iter() + .map(|r| GitPullResolution { + path: r.path, + choice: r.choice, + content: r.content, + }) + .collect(), + message: value.message, + } + } +} + #[utoipa::path(get, path = "/api/git/config", tag = "Git", responses((status = 200, body = Option)))] pub async fn get_config( State(ctx): State, @@ -171,16 +266,6 @@ pub async fn get_config( .await?; workspace_scope::ensure_workspace_permission(&ctx, workspace_id, user_id, PERM_GIT_SYNC) .await?; - workspace_scope::ensure_workspace_permission(&ctx, workspace_id, user_id, PERM_GIT_SYNC) - .await?; - workspace_scope::ensure_workspace_permission(&ctx, workspace_id, user_id, PERM_GIT_CONFIGURE) - .await?; - workspace_scope::ensure_workspace_permission(&ctx, workspace_id, user_id, PERM_GIT_CONFIGURE) - .await?; - workspace_scope::ensure_workspace_permission(&ctx, workspace_id, user_id, PERM_GIT_CONFIGURE) - .await?; - workspace_scope::ensure_workspace_permission(&ctx, workspace_id, user_id, PERM_GIT_CONFIGURE) - .await?; workspace_scope::ensure_workspace_permission(&ctx, workspace_id, user_id, PERM_GIT_CONFIGURE) .await?; let service = ctx.git_service(); @@ -222,14 +307,6 @@ pub async fn create_or_update_config( .await?; workspace_scope::ensure_workspace_permission(&ctx, workspace_id, user_id, PERM_GIT_SYNC) .await?; - workspace_scope::ensure_workspace_permission(&ctx, workspace_id, user_id, PERM_GIT_SYNC) - .await?; - workspace_scope::ensure_workspace_permission(&ctx, workspace_id, user_id, PERM_GIT_CONFIGURE) - .await?; - workspace_scope::ensure_workspace_permission(&ctx, workspace_id, user_id, PERM_GIT_CONFIGURE) - .await?; - workspace_scope::ensure_workspace_permission(&ctx, workspace_id, user_id, PERM_GIT_CONFIGURE) - .await?; workspace_scope::ensure_workspace_permission(&ctx, workspace_id, user_id, PERM_GIT_CONFIGURE) .await?; let input: UpsertGitConfigInput = req.into(); @@ -268,8 +345,6 @@ pub async fn delete_config( user_id, ) .await?; - workspace_scope::ensure_workspace_permission(&ctx, workspace_id, user_id, PERM_GIT_SYNC) - .await?; workspace_scope::ensure_workspace_permission(&ctx, workspace_id, user_id, PERM_GIT_SYNC) .await?; workspace_scope::ensure_workspace_permission(&ctx, workspace_id, user_id, PERM_GIT_CONFIGURE) @@ -282,7 +357,7 @@ pub async fn delete_config( Ok(StatusCode::NO_CONTENT) } -#[derive(Debug, Serialize, ToSchema)] +#[derive(Debug, Serialize, Deserialize, ToSchema, Clone)] pub struct GitStatus { pub repository_initialized: bool, pub has_remote: bool, @@ -542,6 +617,507 @@ pub async fn sync_now( })) } +#[utoipa::path( + post, + path = "/api/git/import", + tag = "Git", + request_body = CreateGitConfigRequest, + responses((status = 200, body = GitImportResponse)) +)] +pub async fn import_repository( + State(ctx): State, + bearer: Bearer, + headers: HeaderMap, + Json(req): Json, +) -> Result, StatusCode> { + if req.repository_url.trim().is_empty() { + return Err(StatusCode::BAD_REQUEST); + } + let bearer_token = bearer.0.clone(); + let sub = validate_bearer(&ctx, bearer).await?; + let user_id = uuid::Uuid::parse_str(&sub).map_err(|_| StatusCode::UNAUTHORIZED)?; + let workspace_id = workspace_scope::resolve_active_workspace_id( + &ctx, + &headers, + Some(bearer_token.as_str()), + user_id, + ) + .await?; + workspace_scope::ensure_workspace_permission(&ctx, workspace_id, user_id, PERM_GIT_INIT) + .await?; + + let service = ctx.git_service(); + let dto = service + .import_repository(workspace_id, user_id, &UpsertGitConfigInput::from(req)) + .await + .map_err(map_git_error)?; + Ok(Json(GitImportResponse { + success: true, + message: dto.message, + files_changed: dto.files_changed as i32, + commit_hash: dto.commit_hash, + docs_created: dto.docs_created as i32, + attachments_created: dto.attachments_created as i32, + })) +} + +#[utoipa::path( + post, + path = "/api/git/pull", + tag = "Git", + request_body = GitPullRequest, + responses( + (status = 200, body = GitPullResponse), + (status = 409, body = GitPullResponse, description = "Conflicts detected") + ) +)] +pub async fn pull_repository( + State(ctx): State, + bearer: Bearer, + headers: HeaderMap, + Json(req): Json, +) -> Result<(StatusCode, Json), StatusCode> { + let bearer_token = bearer.0.clone(); + let sub = validate_bearer(&ctx, bearer).await?; + let user_id = uuid::Uuid::parse_str(&sub).map_err(|_| StatusCode::UNAUTHORIZED)?; + let workspace_id = workspace_scope::resolve_active_workspace_id( + &ctx, + &headers, + Some(bearer_token.as_str()), + user_id, + ) + .await?; + workspace_scope::ensure_workspace_permission(&ctx, workspace_id, user_id, PERM_GIT_SYNC) + .await?; + let service = ctx.git_service(); + let dto = service + .pull_repository( + workspace_id, + user_id, + GitPullRequestDto { + resolutions: req + .resolutions + .unwrap_or_default() + .into_iter() + .map(|r| GitPullResolutionDto { + path: r.path, + choice: r.choice, + content: r.content, + }) + .collect(), + }, + ) + .await + .map_err(|err| { + let message = match &err { + ServiceError::BadRequest("workspace_has_pending_changes") => { + "Workspace has pending changes. Commit, sync, or discard them before pulling." + .to_string() + } + _ => err.to_string(), + }; + let status = map_git_error(err); + let body = GitPullResponse { + success: false, + message, + files_changed: 0, + commit_hash: None, + conflicts: None, + git_status: None, + }; + (status, body) + }); + let dto = match dto { + Ok(v) => v, + Err((status, body)) => return Ok((status, Json(body))), + }; + let conflicts = dto + .conflicts + .map(|items| items.into_iter().map(Into::into).collect::>()) + .unwrap_or_default(); + let has_conflicts = !conflicts.is_empty(); + let status = if has_conflicts { + StatusCode::CONFLICT + } else { + StatusCode::OK + }; + Ok(( + status, + Json(GitPullResponse { + success: dto.success, + message: dto.message, + files_changed: dto.files_changed as i32, + commit_hash: dto.commit_hash, + conflicts: if has_conflicts { Some(conflicts) } else { None }, + git_status: None, + }), + )) +} + +#[utoipa::path( + post, + path = "/api/git/pull/start", + tag = "Git", + responses( + (status = 200, body = GitPullSessionResponse), + (status = 400, body = GitPullSessionResponse), + (status = 409, body = GitPullSessionResponse, description = "Conflicts detected") + ) +)] +pub async fn start_pull_session( + State(ctx): State, + bearer: Bearer, + headers: HeaderMap, +) -> Result<(StatusCode, Json), StatusCode> { + let bearer_token = bearer.0.clone(); + let sub = validate_bearer(&ctx, bearer).await?; + let user_id = uuid::Uuid::parse_str(&sub).map_err(|_| StatusCode::UNAUTHORIZED)?; + let workspace_id = workspace_scope::resolve_active_workspace_id( + &ctx, + &headers, + Some(bearer_token.as_str()), + user_id, + ) + .await?; + workspace_scope::ensure_workspace_permission(&ctx, workspace_id, user_id, PERM_GIT_SYNC) + .await?; + + let service = ctx.git_service(); + let session = match service.start_pull_session_flow(workspace_id, user_id).await { + Ok(v) => v, + Err(err) => { + let message = match &err { + ServiceError::BadRequest("workspace_has_pending_changes") => { + "Workspace has pending changes. Commit, sync, or discard them before pulling." + .to_string() + } + other => other.to_string(), + }; + let status = map_git_error(err); + return Ok(( + status, + Json(GitPullSessionResponse { + session_id: Uuid::nil(), + status: "error".to_string(), + conflicts: Vec::new(), + resolutions: Vec::new(), + message: Some(message), + }), + )); + } + }; + if session.status == "error" { + return Ok(( + StatusCode::BAD_REQUEST, + Json(GitPullSessionResponse { + session_id: session.id, + status: session.status, + conflicts: Vec::new(), + resolutions: Vec::new(), + message: session.message, + }), + )); + } + let conflicts = session + .conflicts + .clone() + .into_iter() + .map(Into::into) + .collect::>(); + let has_conflicts = !conflicts.is_empty(); + let status = if has_conflicts { + StatusCode::CONFLICT + } else { + StatusCode::OK + }; + Ok(( + status, + Json(GitPullSessionResponse { + session_id: session.id, + status: session.status, + conflicts, + resolutions: Vec::new(), + message: session.message, + }), + )) +} + +#[utoipa::path( + get, + path = "/api/git/pull/session/{id}", + tag = "Git", + responses((status = 200, body = GitPullSessionResponse)) +)] +pub async fn get_pull_session( + State(ctx): State, + bearer: Bearer, + headers: HeaderMap, + axum::extract::Path(id): axum::extract::Path, +) -> Result, StatusCode> { + let bearer_token = bearer.0.clone(); + let sub = validate_bearer(&ctx, bearer).await?; + let user_id = uuid::Uuid::parse_str(&sub).map_err(|_| StatusCode::UNAUTHORIZED)?; + let workspace_id = workspace_scope::resolve_active_workspace_id( + &ctx, + &headers, + Some(bearer_token.as_str()), + user_id, + ) + .await?; + workspace_scope::ensure_workspace_permission(&ctx, workspace_id, user_id, PERM_GIT_SYNC) + .await?; + + let service = ctx.git_service(); + let state = service + .load_pull_session_with_stale_check(workspace_id, id) + .await + .map_err(|err| { + let status = map_git_error(err); + status + })? + .ok_or(StatusCode::NOT_FOUND)?; + Ok(Json(GitPullSessionResponse { + session_id: state.id, + status: state.status, + conflicts: state.conflicts.into_iter().map(Into::into).collect(), + resolutions: state + .resolutions + .into_iter() + .map(|r| GitPullResolution { + path: r.path, + choice: r.choice, + content: r.content, + }) + .collect(), + message: state.message, + })) +} + +#[utoipa::path( + post, + path = "/api/git/pull/session/{id}/resolve", + tag = "Git", + request_body = GitPullRequest, + responses( + (status = 200, body = GitPullSessionResponse), + (status = 400, body = GitPullSessionResponse), + (status = 409, body = GitPullSessionResponse) + ) +)] +pub async fn resolve_pull_session( + State(ctx): State, + bearer: Bearer, + headers: HeaderMap, + axum::extract::Path(id): axum::extract::Path, + Json(req): Json, +) -> Result<(StatusCode, Json), StatusCode> { + let bearer_token = bearer.0.clone(); + let sub = validate_bearer(&ctx, bearer).await?; + let user_id = uuid::Uuid::parse_str(&sub).map_err(|_| StatusCode::UNAUTHORIZED)?; + let workspace_id = workspace_scope::resolve_active_workspace_id( + &ctx, + &headers, + Some(bearer_token.as_str()), + user_id, + ) + .await?; + workspace_scope::ensure_workspace_permission(&ctx, workspace_id, user_id, PERM_GIT_SYNC) + .await?; + + let service = ctx.git_service(); + let existing_session = service + .load_pull_session_with_stale_check(workspace_id, id) + .await + .map_err(map_git_error)? + .ok_or(StatusCode::NOT_FOUND)?; + let resolutions = req.resolutions.unwrap_or_default(); + let session = match service + .resolve_pull_session_flow( + workspace_id, + user_id, + id, + resolutions + .iter() + .cloned() + .map(|r| GitPullResolutionDto { + path: r.path, + choice: r.choice, + content: r.content, + }) + .collect(), + ) + .await + { + Ok(v) => v, + Err(err) => { + let message = match &err { + ServiceError::BadRequest("workspace_has_pending_changes") => { + "Workspace has pending changes. Commit, sync, or discard them before pulling." + .to_string() + } + other => other.to_string(), + }; + let status = map_git_error(err); + return Ok(( + status, + Json(GitPullSessionResponse { + session_id: id, + status: "error".to_string(), + conflicts: existing_session + .conflicts + .into_iter() + .map(Into::into) + .collect(), + resolutions: existing_session + .resolutions + .into_iter() + .map(|r| GitPullResolution { + path: r.path, + choice: r.choice, + content: r.content, + }) + .collect(), + message: Some(message), + }), + )); + } + }; + + let mut status_code = StatusCode::OK; + + let conflicts: Vec = session + .conflicts + .clone() + .into_iter() + .map(Into::into) + .collect(); + if !conflicts.is_empty() { + status_code = StatusCode::CONFLICT; + } + if session.status == "stale" { + status_code = StatusCode::CONFLICT; + } + if session.status == "error" { + status_code = StatusCode::BAD_REQUEST; + } + let session_status = session.status.clone(); + + Ok(( + status_code, + Json(GitPullSessionResponse { + session_id: id, + status: session_status.clone(), + conflicts, + resolutions, + message: if session_status == "error" { + session.message + } else if status_code == StatusCode::CONFLICT && session_status == "stale" { + Some("Pull session is stale. Please start a new pull.".to_string()) + } else { + session.message + }, + }), + )) +} + +#[utoipa::path( + post, + path = "/api/git/pull/session/{id}/finalize", + tag = "Git", + responses( + (status = 200, body = GitPullResponse), + (status = 400, body = GitPullResponse), + (status = 409, body = GitPullResponse) + ) +)] +pub async fn finalize_pull_session( + State(ctx): State, + bearer: Bearer, + headers: HeaderMap, + axum::extract::Path(id): axum::extract::Path, +) -> Result<(StatusCode, Json), StatusCode> { + let bearer_token = bearer.0.clone(); + let sub = validate_bearer(&ctx, bearer).await?; + let user_id = uuid::Uuid::parse_str(&sub).map_err(|_| StatusCode::UNAUTHORIZED)?; + let workspace_id = workspace_scope::resolve_active_workspace_id( + &ctx, + &headers, + Some(bearer_token.as_str()), + user_id, + ) + .await?; + workspace_scope::ensure_workspace_permission(&ctx, workspace_id, user_id, PERM_GIT_SYNC) + .await?; + + let service = ctx.git_service(); + let FinalizePullSessionResult { + session, + git_status, + } = service + .finalize_pull_session_flow(workspace_id, id) + .await + .map_err(map_git_error)?; + if session.status == "error" { + return Ok(( + StatusCode::BAD_REQUEST, + Json(GitPullResponse { + success: false, + message: session + .message + .clone() + .unwrap_or_else(|| "pull failed".to_string()), + files_changed: 0, + commit_hash: None, + conflicts: Some(session.conflicts.into_iter().map(Into::into).collect()), + git_status: None, + }), + )); + } + if session.status == "stale" { + return Ok(( + StatusCode::CONFLICT, + Json(GitPullResponse { + success: false, + message: session + .message + .clone() + .unwrap_or_else(|| "pull session stale".to_string()), + files_changed: 0, + commit_hash: None, + conflicts: Some(session.conflicts.into_iter().map(Into::into).collect()), + git_status: None, + }), + )); + } + if !session.conflicts.is_empty() { + return Ok(( + StatusCode::CONFLICT, + Json(GitPullResponse { + success: false, + message: "conflicts remaining".to_string(), + files_changed: 0, + commit_hash: None, + conflicts: Some(session.conflicts.into_iter().map(Into::into).collect()), + git_status: None, + }), + )); + } + Ok(( + StatusCode::OK, + Json(GitPullResponse { + success: true, + message: session + .message + .clone() + .unwrap_or_else(|| "merge completed".to_string()), + files_changed: 0, + commit_hash: None, + conflicts: None, + git_status: git_status.map(Into::into), + }), + )) +} + #[derive(Debug, Serialize, ToSchema)] pub struct GitChangeItem { pub path: String, diff --git a/app/src/entities/git/api/index.ts b/app/src/entities/git/api/index.ts index a91bf805..520eeecc 100644 --- a/app/src/entities/git/api/index.ts +++ b/app/src/entities/git/api/index.ts @@ -10,9 +10,26 @@ import { ignoreDocument as apiIgnoreDocument, ignoreFolder as apiIgnoreFolder, initRepository as apiInitRepository, + pullRepository as apiPullRepository, + startPullSession as apiStartPullSession, + getPullSession as apiGetPullSession, + resolvePullSession as apiResolvePullSession, + finalizePullSession as apiFinalizePullSession, + importRepository as apiImportRepository, syncNow as apiSyncNow, } from '@/shared/api' -import type { GitChangesResponse, GitHistoryResponse, GitStatus, TextDiffResult } from '@/shared/api' +import type { + GitChangesResponse, + GitHistoryResponse, + GitImportResponse, + GitPullResponse, + GitPullSessionResponse, + GitStatus, + ImportRepositoryData, + ImportRepositoryResponse, + PullRepositoryData, + TextDiffResult, +} from '@/shared/api' export const gitKeys = { all: ['git'] as const, @@ -51,7 +68,22 @@ export { apiCreateOrUpdateConfig as createOrUpdateConfig, apiDeinitRepository as deinitRepository, apiInitRepository as initRepository, + apiPullRepository as pullRepository, + apiStartPullSession as startPullSession, + apiGetPullSession as getPullSession, + apiResolvePullSession as resolvePullSession, + apiFinalizePullSession as finalizePullSession, + apiImportRepository as importRepository, apiSyncNow as syncNow, apiIgnoreDocument as ignoreDocument, apiIgnoreFolder as ignoreFolder, } + +export type { + GitImportResponse, + GitPullResponse, + GitPullSessionResponse, + ImportRepositoryData, + ImportRepositoryResponse, + PullRepositoryData, +} diff --git a/app/src/features/auth/lib/types.ts b/app/src/features/auth/lib/types.ts index da34936b..a2c159b4 100644 --- a/app/src/features/auth/lib/types.ts +++ b/app/src/features/auth/lib/types.ts @@ -2,7 +2,7 @@ import type { UserResponse } from '@/shared/api' export type AuthRedirectTarget = { to: string - search?: Record + search?: Record } export type AuthMiddlewareContext = { diff --git a/app/src/features/edit-document/containers/MarkdownEditor.tsx b/app/src/features/edit-document/containers/MarkdownEditor.tsx index 2abb6e54..2887651a 100644 --- a/app/src/features/edit-document/containers/MarkdownEditor.tsx +++ b/app/src/features/edit-document/containers/MarkdownEditor.tsx @@ -24,4 +24,3 @@ export function MarkdownEditor(props: MarkdownEditorProps) { } export default MarkdownEditor - diff --git a/app/src/features/edit-document/lib/monaco/theme.ts b/app/src/features/edit-document/lib/monaco/theme.ts index 664dd22f..3ef832d2 100644 --- a/app/src/features/edit-document/lib/monaco/theme.ts +++ b/app/src/features/edit-document/lib/monaco/theme.ts @@ -120,6 +120,9 @@ const buildTheme = (name: string, palette: Palette, isDark: boolean): ThemeDefin 'editorIndentGuide.activeBackground': hexWithAlpha(palette.primary, isDark ? 0.3 : 0.24), 'editor.lineHighlightBackground': hexWithAlpha(palette.primary, isDark ? 0.08 : 0.06), 'editor.selectionHighlightBorder': hexWithAlpha(palette.foreground, 0.16), + // Diff: align with Git History/Changes/Snapshot viewer colors + 'diffEditor.insertedTextBackground': isDark ? hexWithAlpha('#052e16', 0.4) : '#f0fdf4', + 'diffEditor.removedTextBackground': isDark ? hexWithAlpha('#450a0a', 0.4) : '#fef2f2', }, }, } diff --git a/app/src/features/edit-document/ui/Editor.tsx b/app/src/features/edit-document/ui/Editor.tsx index 1f21527c..1d3e5f1b 100644 --- a/app/src/features/edit-document/ui/Editor.tsx +++ b/app/src/features/edit-document/ui/Editor.tsx @@ -56,12 +56,49 @@ export type MarkdownEditorProps = { documentId: string readOnly?: boolean extraRight?: React.ReactNode + conflictControls?: React.ReactNode + conflictHunkWidgets?: Array<{ + id: string + line: number + choice?: 'ours' | 'theirs' + onChoose: (side: 'ours' | 'theirs') => void + }> + conflictBadgeText?: string + conflictView?: { + kind: 'text' | 'binary' + original?: string + modified?: string + onChange?: (value: string) => void + readOnly?: boolean + actions?: { + onKeepMine?: () => void + onTakeTheirs?: () => void + onApplyMerged?: () => void + } + theme?: string + } + previewOverride?: string renderPreview?: (props: PreviewPaneProps) => React.ReactNode } export function MarkdownEditor(props: MarkdownEditorProps) { - const { doc, awareness, initialView: initialViewProp = 'split', userId, userName, documentId, readOnly = false, extraRight, renderPreview } = props + const { + doc, + awareness, + initialView: initialViewProp = 'split', + userId, + userName, + documentId, + readOnly = false, + extraRight, + conflictControls, + conflictHunkWidgets, + conflictBadgeText, + conflictView, + previewOverride, + renderPreview, + } = props const { isDarkMode } = useTheme() const isMobile = useIsMobile() const { setEditor } = useEditorContext() @@ -536,11 +573,23 @@ export function MarkdownEditor(props: MarkdownEditorProps) { onPreviewNavigate={onPreviewNavigate} documentId={documentId} onToggleTask={handleTaskToggle} + previewContentOverride={previewOverride} content={boundText} vimStatusBarRef={vimStatusBarRef} showVimStatusBar={isVimMode} uploadStatus={uploadStatus} renderPreview={renderPreview} + conflictControls={conflictControls} + conflictBadgeText={conflictBadgeText} + conflictHunkWidgets={conflictHunkWidgets} + conflictView={ + conflictView + ? { + ...conflictView, + theme: monacoTheme, + } + : undefined + } /> diff --git a/app/src/features/edit-document/ui/EditorLayout.tsx b/app/src/features/edit-document/ui/EditorLayout.tsx index 9d1ea196..adf6816e 100644 --- a/app/src/features/edit-document/ui/EditorLayout.tsx +++ b/app/src/features/edit-document/ui/EditorLayout.tsx @@ -1,6 +1,7 @@ +import { DiffEditor } from '@monaco-editor/react' import { AlertTriangle, Check, Loader2, SlidersHorizontal, X } from 'lucide-react' -import type * as monacoNs from 'monaco-editor' -import { useCallback, useMemo, type CSSProperties, type ReactNode, type MutableRefObject } from 'react' +import * as monacoNs from 'monaco-editor' +import { useCallback, useMemo, useEffect, useRef, useState, type CSSProperties, type ReactNode, type MutableRefObject } from 'react' import { overlayPanelClass } from '@/shared/lib/overlay-classes' import { cn } from '@/shared/lib/utils' @@ -8,6 +9,7 @@ import type { ViewMode } from '@/shared/types/view-mode' import { Button } from '@/shared/ui/button' import type { UploadStatus } from '@/features/edit-document/hooks/useEditorUploads' +import { ensureRefmdThemes } from '@/features/edit-document/lib/monaco/theme' import EditorPane from './EditorPane' import PreviewPane, { type PreviewPaneProps } from './PreviewPane' @@ -34,10 +36,34 @@ export type EditorLayoutProps = { documentId: string onToggleTask?: (lineNumber: number, checked: boolean) => void content: string + previewContentOverride?: string vimStatusBarRef: MutableRefObject showVimStatusBar: boolean uploadStatus: UploadStatus renderPreview?: (props: PreviewPaneProps) => ReactNode + editorOverlay?: ReactNode + editorBanner?: ReactNode + conflictControls?: ReactNode + conflictBadgeText?: string + conflictHunkWidgets?: Array<{ + id: string + line: number + choice?: 'ours' | 'theirs' + onChoose: (side: 'ours' | 'theirs') => void + }> + conflictView?: { + kind: 'text' | 'binary' + original?: string + modified?: string + onChange?: (val: string) => void + readOnly?: boolean + theme?: string + actions?: { + onKeepMine?: () => void + onTakeTheirs?: () => void + onApplyMerged?: () => void + } + } } export function EditorLayout({ @@ -62,11 +88,177 @@ export function EditorLayout({ documentId, onToggleTask, content, + previewContentOverride, vimStatusBarRef, showVimStatusBar, uploadStatus, renderPreview, + editorOverlay, + editorBanner, + conflictControls, + conflictBadgeText, + conflictHunkWidgets, + conflictView, }: EditorLayoutProps) { + const diffEditorRef = useRef(null) + const monacoRef = useRef(null) + const [diffReady, setDiffReady] = useState(false) + const overlayNodesRef = useRef>({}) + const overlayWidgetsRef = useRef>({}) + const overlayDisposablesRef = useRef([]) + + useEffect(() => { + // cleanup helper + const cleanup = () => { + if (diffEditorRef.current && monacoRef.current) { + const modified = diffEditorRef.current.getModifiedEditor() + Object.values(overlayWidgetsRef.current).forEach((widget) => { + try { + modified.removeContentWidget(widget) + } catch { + /* ignore */ + } + }) + } + Object.values(overlayNodesRef.current).forEach((node) => node.remove()) + overlayNodesRef.current = {} + overlayWidgetsRef.current = {} + overlayDisposablesRef.current.forEach((d) => d.dispose()) + overlayDisposablesRef.current = [] + } + + const diff = diffEditorRef.current + const monacoInstance = monacoRef.current + if (!diff || !monacoInstance || !diffReady) { + cleanup() + return + } + const modified = diff.getModifiedEditor() + const model = modified?.getModel() + if (!modified || !model || !conflictHunkWidgets || conflictHunkWidgets.length === 0) { + cleanup() + return + } + + const host = + modified.getDomNode()?.querySelector('.overflow-guard') ?? + modified.getDomNode() ?? + document.createElement('div') + if (!host) { + cleanup() + return + } + if (host instanceof HTMLElement) { + const style = host.style + if (!style.position || style.position === 'static') { + style.position = 'relative' + } + } + + const isDark = typeof document !== 'undefined' && document.documentElement.classList.contains('dark') + const palette = { + ours: { + bg: isDark ? 'rgba(127,29,29,0.30)' : '#fef2f2', + bgActive: isDark ? 'rgba(185,28,28,0.55)' : '#fee2e2', + color: isDark ? '#fecdd3' : '#b91c1c', + }, + theirs: { + bg: isDark ? 'rgba(5,46,22,0.30)' : '#f0fdf4', + bgActive: isDark ? 'rgba(34,197,94,0.50)' : '#dcfce7', + color: isDark ? '#bbf7d0' : '#166534', + }, + } + + const createNode = (hunk: typeof conflictHunkWidgets[number]) => { + const node = document.createElement('div') + node.style.position = 'absolute' + node.style.display = 'inline-flex' + node.style.flexDirection = 'row' + node.style.alignItems = 'center' + node.style.gap = '8px' + node.style.padding = '2px 6px' + node.style.borderRadius = '10px' + node.style.background = 'transparent' + node.style.pointerEvents = 'auto' + node.style.whiteSpace = 'nowrap' + node.style.zIndex = '50' + node.style.marginLeft = '8px' + + const makeBtn = (label: string, side: 'ours' | 'theirs') => { + const btn = document.createElement('button') + btn.textContent = label + btn.style.fontSize = '11px' + btn.style.padding = '4px 10px' + btn.style.borderRadius = '8px' + btn.style.border = 'none' + btn.style.cursor = 'pointer' + btn.style.lineHeight = '1' + btn.style.fontWeight = hunk.choice === side ? '700' : '500' + btn.style.display = 'inline-flex' + btn.style.alignItems = 'center' + btn.style.justifyContent = 'center' + const colors = side === 'ours' ? palette.ours : palette.theirs + btn.style.background = hunk.choice === side ? colors.bgActive : colors.bg + btn.style.color = colors.color + btn.onmousedown = (e) => { + e.preventDefault() + e.stopPropagation() + } + btn.onclick = (e) => { + e.preventDefault() + e.stopPropagation() + hunk.onChoose(side) + } + return btn + } + + node.appendChild(makeBtn('Keep Mine', 'ours')) + node.appendChild(makeBtn('Take Remote', 'theirs')) + return node + } + + conflictHunkWidgets.forEach((hunk) => { + const node = createNode(hunk) + overlayNodesRef.current[hunk.id] = node + const widget: monacoNs.editor.IContentWidget = { + getId: () => `conflict-hunk-${hunk.id}`, + getDomNode: () => node, + getPosition: () => ({ + position: { + lineNumber: Math.max(hunk.line, 1), + // Place at line end so it follows text instead of gutter. + column: + (modified.getModel()?.getLineMaxColumn(Math.max(hunk.line, 1)) ?? 1) + + 1, + }, + preference: [monacoNs.editor.ContentWidgetPositionPreference.EXACT], + }), + } + overlayWidgetsRef.current[hunk.id] = widget + modified.addContentWidget(widget) + }) + + const relayout = () => { + Object.values(overlayWidgetsRef.current).forEach((widget) => { + try { + modified.layoutContentWidget(widget) + } catch { + /* ignore */ + } + }) + } + + relayout() + + overlayDisposablesRef.current.push( + modified.onDidScrollChange(() => relayout()), + modified.onDidLayoutChange(() => relayout()), + modified.onDidChangeConfiguration(() => relayout()), + ) + + return cleanup + }, [conflictHunkWidgets, diffReady]) + const uploadStatusNode = (() => { if (uploadStatus.state === 'idle') return null let primary = '' @@ -193,11 +385,17 @@ export function EditorLayout({ >
+ 'flex flex-1 min-h-0 flex-col', + !isMobile && 'px-4 pb-6 pt-6 sm:px-6 sm:pb-8 sm:pt-8', + )} + > + {editorBanner ?
{editorBanner}
: null}
+ {editorOverlay ? ( +
+ {editorOverlay} +
+ ) : null}
{uploadStatusNode} {toolbarOpen ? ( @@ -228,18 +426,87 @@ export function EditorLayout({ )}
- { - if (!readOnly) await onEditorDropFiles(files) - }} - isMobile={isMobile} - onMount={onEditorMount} - vimStatusBarRef={vimStatusBarRef} - showVimStatusBar={showVimStatusBar} - /> + {conflictView && conflictView.kind === 'text' ? ( +
+ {conflictControls ?
{conflictControls}
: null} +
+ { + monacoRef.current = monacoInstance + ensureRefmdThemes(monacoInstance) + }} + onMount={(editor, monacoInstance) => { + diffEditorRef.current = editor + monacoRef.current = monacoInstance + setDiffReady(true) + monacoInstance.editor.setTheme(conflictView.theme ?? monacoTheme) + const modified = editor.getModifiedEditor() + const original = editor.getOriginalEditor() + // Align gutters; show line numbers only on original + original.updateOptions({ + glyphMargin: false, + lineDecorationsWidth: 24, + lineNumbersMinChars: 1, // Monaco enforces >=1 + lineNumbers: 'on' as const, + }) + modified.updateOptions({ + glyphMargin: false, + lineDecorationsWidth: 24, + lineNumbersMinChars: 1, // Monaco enforces >=1 + lineNumbers: 'off' as const, + }) + if (conflictView.onChange) { + modified.onDidChangeModelContent(() => { + conflictView.onChange?.(modified.getValue()) + }) + } + }} + language="markdown" + theme={conflictView.theme ?? monacoTheme} + options={{ + readOnly: conflictView.readOnly, + renderSideBySide: false, + renderMarginRevertIcon: false, + renderOverviewRuler: false, + renderIndicators: false, + minimap: { enabled: false }, + automaticLayout: true, + wordWrap: 'on', + scrollBeyondLastLine: true, + fontSize: isMobile ? 17 : 14, + lineHeight: isMobile ? 26 : 22, + }} + /> +
+ {conflictHunkWidgets && conflictHunkWidgets.length ? ( +
+
+ + {conflictBadgeText || `${conflictHunkWidgets.length} hunks`} +
+
+ ) : null} +
+ ) : conflictView && conflictView.kind === 'binary' ? ( +
+ Binary conflict. Choose a side to continue. +
+ ) : ( + { + if (!readOnly) await onEditorDropFiles(files) + }} + isMobile={isMobile} + onMount={onEditorMount} + vimStatusBarRef={vimStatusBarRef} + showVimStatusBar={showVimStatusBar} + /> + )}
@@ -268,7 +535,7 @@ export function EditorLayout({ > {(() => { const previewProps: PreviewPaneProps = { - content, + content: previewContentOverride ?? content, forceFloatingToc: layoutState.shouldForceFloatingToc, viewMode: view === 'split' ? 'split' : 'preview', onNavigate: onPreviewNavigate, diff --git a/app/src/features/file-tree/model/file-tree-context.tsx b/app/src/features/file-tree/model/file-tree-context.tsx index c0e4d85c..0ed83789 100644 --- a/app/src/features/file-tree/model/file-tree-context.tsx +++ b/app/src/features/file-tree/model/file-tree-context.tsx @@ -54,6 +54,8 @@ type DbDoc = { is_share_mount?: boolean share_mount_id?: string created_by_plugin?: string | null + path?: string | null + desired_path?: string | null } type BuildTreeOptions = { @@ -95,6 +97,8 @@ function buildTree(docs: DbDoc[], options?: BuildTreeOptions): DocumentNode[] { sourceId: d.source_id, title: d.title, type, + path: d.path ?? null, + desiredPath: d.desired_path ?? null, children: [], created_at: d.created_at, updated_at: d.updated_at, diff --git a/app/src/features/file-tree/model/types.ts b/app/src/features/file-tree/model/types.ts index 7e8a7e7e..fc36f05b 100644 --- a/app/src/features/file-tree/model/types.ts +++ b/app/src/features/file-tree/model/types.ts @@ -5,6 +5,8 @@ export type DocumentNode = { type: 'file' | 'folder' // Follows source structure for mounted shares children?: DocumentNode[] + path?: string | null + desiredPath?: string | null created_at?: string updated_at?: string archived?: boolean diff --git a/app/src/features/file-tree/ui/FileNode.tsx b/app/src/features/file-tree/ui/FileNode.tsx index a4716a32..584fd060 100644 --- a/app/src/features/file-tree/ui/FileNode.tsx +++ b/app/src/features/file-tree/ui/FileNode.tsx @@ -1,6 +1,7 @@ "use client" import { useQueries } from '@tanstack/react-query' +import { useRouter } from '@tanstack/react-router' import { FileText, Edit, @@ -11,6 +12,7 @@ import { Globe, Link as LinkIcon, Ban, + AlertTriangle, MessageSquare, Blocks, StickyNote, @@ -27,6 +29,7 @@ import type { LucideIcon } from 'lucide-react' import React, { useState, useCallback, memo, useEffect, useRef } from 'react' import { toast } from 'sonner' +import type { GitPullConflictItem } from '@/shared/api' import useInView from '@/shared/hooks/use-in-view' import { overlayMenuClass } from '@/shared/lib/overlay-classes' import { cn } from '@/shared/lib/utils' @@ -67,6 +70,7 @@ type FileNodeProps = { pluginRules?: FileTreeRule[] onOpenSecondaryViewer?: (id: string, type?: 'document' | 'scrap') => void gitEnabled?: boolean + conflict?: GitPullConflictItem | null } export const FileNode = memo(function FileNode({ @@ -90,6 +94,7 @@ export const FileNode = memo(function FileNode({ pluginRules, onOpenSecondaryViewer, gitEnabled = false, + conflict = null, }: FileNodeProps) { const { sharedDocIds, @@ -100,6 +105,7 @@ export const FileNode = memo(function FileNode({ refreshDocuments, setArchivesExpanded, } = useFileTree() + const router = useRouter() const rowRef = useRef(null) const isRowInView = useInView(rowRef, { rootMargin: '160px' }) const [hasBeenVisible, setHasBeenVisible] = useState(false) @@ -110,6 +116,7 @@ export const FileNode = memo(function FileNode({ const menuGuardRef = useRef<{ block: boolean; timer?: number }>({ block: false }) const isArchived = Boolean(node.archived) const isShareMount = Boolean(node.isShareMount) + const hasConflict = Boolean(conflict) const archiveMutation = useArchiveDocument() const unarchiveMutation = useUnarchiveDocument() @@ -177,6 +184,13 @@ export const FileNode = memo(function FileNode({ } }, [isShareMount, node, onDuplicate]) const handleSelect = useCallback(() => { onSelect(node) }, [node, onSelect]) + const handleOpenConflictResolver = useCallback(() => { + router.navigate({ + to: '/document/$id', + params: { id: node.id }, + search: (prev: Record) => ({ ...prev, conflict: '1' }), + }) + }, [node.id, router]) const handleArchive = useCallback(async () => { if (isShareMount) return try { @@ -444,6 +458,12 @@ export const FileNode = memo(function FileNode({ {sharedDocIds.has(node.id) && } )} + {hasConflict && ( + + + Conflict + + )} @@ -492,6 +512,13 @@ export const FileNode = memo(function FileNode({ > Open in Secondary Viewer + {hasConflict && !isShareMount && ( + guardMenuAction(event, handleOpenConflictResolver)} + > + Open in Conflict Resolver + + )} {!isShareMount && ( <> {gitEnabled && ( @@ -559,7 +586,8 @@ export const FileNode = memo(function FileNode({ prev.isSelected === next.isSelected && prev.isDragging === next.isDragging && prev.isDropTarget === next.isDropTarget && - prev.gitEnabled === next.gitEnabled + prev.gitEnabled === next.gitEnabled && + (prev.conflict?.path || null) === (next.conflict?.path || null) )) export default FileNode diff --git a/app/src/features/git-sync/index.ts b/app/src/features/git-sync/index.ts index 10401645..4569f656 100644 --- a/app/src/features/git-sync/index.ts +++ b/app/src/features/git-sync/index.ts @@ -2,5 +2,6 @@ export { default as GitSyncButton } from './ui/git-sync-button' export { default as GitConfigDialog } from './ui/git-config-dialog' export { default as GitHistoryDialog } from './ui/git-history-dialog' export { default as GitChangesDialog } from './ui/git-changes-dialog' +export { default as GitPullDialog } from './ui/git-pull-dialog' export * from './ui/commit-diff-panel' export * from './ui/working-diff-panel' diff --git a/app/src/features/git-sync/lib/git-conflict-store.ts b/app/src/features/git-sync/lib/git-conflict-store.ts new file mode 100644 index 00000000..77097d13 --- /dev/null +++ b/app/src/features/git-sync/lib/git-conflict-store.ts @@ -0,0 +1,161 @@ +import { getClientWorkspaceId } from '@/shared/api/client.config' +import type { GitPullConflictItem, GitPullResolution } from '@/shared/api' + +export const GIT_CONFLICT_EVENT = 'refmd:git-conflicts-updated' +export const GIT_SESSION_EVENT = 'refmd:git-session-updated' + +let currentConflicts: GitPullConflictItem[] = [] +let currentResolutions: GitPullResolution[] = [] +let currentSessionId: string | null = null +let currentWorkspaceId: string | null = null + +const STORAGE_CONFLICTS_KEY = 'refmd:git-conflicts' +const STORAGE_RESOLUTIONS_KEY = 'refmd:git-conflict-resolutions' +const STORAGE_SESSION_KEY = 'refmd:git-conflict-session' + +type StoredArray = { items: T[]; found: boolean } + +const normalizeWorkspaceId = (value: string | null | undefined) => { + if (typeof value !== 'string') return null + const trimmed = value.trim() + return trimmed.length > 0 ? trimmed : null +} + +const scopedKey = (base: string, workspaceId: string | null) => (workspaceId ? `${base}:${workspaceId}` : base) + +const refreshWorkspaceState = () => { + const workspaceId = normalizeWorkspaceId(getClientWorkspaceId()) + currentWorkspaceId = workspaceId + currentConflicts = loadScopedArray(STORAGE_CONFLICTS_KEY, currentWorkspaceId).items + currentResolutions = loadScopedArray(STORAGE_RESOLUTIONS_KEY, currentWorkspaceId).items + const sid = loadScopedArray(STORAGE_SESSION_KEY, currentWorkspaceId) + currentSessionId = sid.items.length ? sid.items[0] : null +} + +const loadFromStorage = (key: string): StoredArray => { + if (typeof window === 'undefined') return { items: [], found: false } + try { + const raw = window.localStorage.getItem(key) + if (raw === null) return { items: [], found: false } + const parsed = JSON.parse(raw) + return Array.isArray(parsed) ? { items: parsed as T[], found: true } : { items: [], found: true } + } catch { + return { items: [], found: false } + } +} + +const persistStorage = (key: string, value: unknown) => { + if (typeof window === 'undefined') return + try { + window.localStorage.setItem(key, JSON.stringify(value)) + } catch { + /* ignore */ + } +} + +const loadScopedArray = (baseKey: string, workspaceId: string | null): StoredArray => { + const scoped = scopedKey(baseKey, workspaceId) + const scopedValue = loadFromStorage(scoped) + if (scopedValue.found) return scopedValue + + // Do not leak legacy (unscoped) values into another workspace; only use legacy when no workspace is selected. + if (workspaceId) return { items: [], found: false } + + return loadFromStorage(baseKey) +} + +// Hydrate initial state from storage when on client +if (typeof window !== 'undefined') { + refreshWorkspaceState() +} + +export const readConflicts = (): GitPullConflictItem[] => { + refreshWorkspaceState() + return currentConflicts.slice() +} +export const readResolutions = (): GitPullResolution[] => { + refreshWorkspaceState() + return currentResolutions.slice() +} +export const readSessionId = (): string | null => { + refreshWorkspaceState() + return currentSessionId +} + +export const setConflicts = (conflicts: GitPullConflictItem[] | null | undefined) => { + refreshWorkspaceState() + currentConflicts = Array.isArray(conflicts) ? conflicts.slice() : [] + persistStorage(scopedKey(STORAGE_CONFLICTS_KEY, currentWorkspaceId), currentConflicts) + // Clear resolutions if conflicts are cleared + if (!currentConflicts.length) { + setResolutions([]) + } + if (typeof window !== 'undefined') { + window.dispatchEvent(new CustomEvent(GIT_CONFLICT_EVENT, { detail: currentConflicts })) + } +} + +export const setResolutions = (resolutions: GitPullResolution[] | null | undefined) => { + refreshWorkspaceState() + currentResolutions = Array.isArray(resolutions) ? resolutions.slice() : [] + persistStorage(scopedKey(STORAGE_RESOLUTIONS_KEY, currentWorkspaceId), currentResolutions) +} + +export const clearResolutions = () => setResolutions([]) + +export const setSessionId = (sessionId: string | null) => { + refreshWorkspaceState() + currentSessionId = sessionId || null + persistStorage(scopedKey(STORAGE_SESSION_KEY, currentWorkspaceId), sessionId ? [sessionId] : []) + if (typeof window !== 'undefined') { + window.dispatchEvent(new CustomEvent(GIT_SESSION_EVENT, { detail: currentSessionId })) + } +} + +export const clearSession = () => { + clearAllConflicts() +} + +export const clearAllConflicts = () => { + setConflicts([]) + setSessionId(null) +} + +export const subscribeConflicts = (handler: (items: GitPullConflictItem[]) => void) => { + if (typeof window === 'undefined') return () => {} + refreshWorkspaceState() + const listener = (event: Event) => { + const detail = (event as CustomEvent).detail || currentConflicts + handler(detail) + } + const storageListener = () => { + refreshWorkspaceState() + handler(readConflicts()) + } + window.addEventListener(GIT_CONFLICT_EVENT, listener) + window.addEventListener('storage', storageListener) + return () => { + window.removeEventListener(GIT_CONFLICT_EVENT, listener) + window.removeEventListener('storage', storageListener) + } +} + +export const subscribeSessionId = (handler: (sessionId: string | null) => void) => { + if (typeof window === 'undefined') return () => {} + refreshWorkspaceState() + const storageListener = (event: StorageEvent) => { + refreshWorkspaceState() + if (event.key && event.key !== scopedKey(STORAGE_SESSION_KEY, currentWorkspaceId)) return + handler(readSessionId()) + } + const eventListener = (event: Event) => { + const detail = (event as CustomEvent).detail + handler(detail ?? readSessionId()) + } + window.addEventListener('storage', storageListener) + window.addEventListener(GIT_SESSION_EVENT, eventListener) + return () => { + window.removeEventListener('storage', storageListener) + window.removeEventListener(GIT_SESSION_EVENT, eventListener) + } +} diff --git a/app/src/features/git-sync/lib/pull-session-manager.ts b/app/src/features/git-sync/lib/pull-session-manager.ts new file mode 100644 index 00000000..43340303 --- /dev/null +++ b/app/src/features/git-sync/lib/pull-session-manager.ts @@ -0,0 +1,177 @@ +import { finalizePullSession, resolvePullSession, startPullSession } from '@/entities/git' +import { ApiError, type GitPullConflictItem, type GitPullResolution, type GitPullSessionResponse } from '@/shared/api' + +import { clearAllConflicts, clearSession, clearResolutions, readConflicts, readResolutions, readSessionId, setConflicts, setSessionId } from './git-conflict-store' + +export type PullSessionResult = { + status: 'merged' | 'conflicts' | 'stale' | 'error' + conflicts: GitPullConflictItem[] + sessionId?: string | null + message?: string | null + emptyConflictWarning?: boolean +} + +const extractConflicts = (value: unknown): GitPullConflictItem[] => { + const toArr = (v: unknown): GitPullConflictItem[] => (Array.isArray(v) ? (v as GitPullConflictItem[]) : []) + if (!value) return [] + if (Array.isArray(value)) return toArr(value) + if (typeof value === 'object') { + const maybe = (value as any)?.conflicts + if (Array.isArray(maybe)) return toArr(maybe) + } + return [] +} + +export const performPullSession = async ( + resolutions?: GitPullResolution[], + options?: { sessionId?: string | null; autoFinalize?: boolean }, +): Promise => { + const sessionIdFromStore = readSessionId() + const sessionId: string | undefined = (options?.sessionId ?? sessionIdFromStore) || undefined + let requestResolutions = resolutions ?? readResolutions() + + if (!sessionId) { + requestResolutions = resolutions ?? [] + clearResolutions() + } + try { + const res: GitPullSessionResponse = sessionId + ? await resolvePullSession({ id: sessionId, requestBody: { resolutions: requestResolutions } }) + : await startPullSession() + + if ((res as any)?.status === 'stale') { + clearAllConflicts() + return { + status: 'stale', + conflicts: [], + sessionId: undefined, + message: res.message, + } + } + + const conflicts = res.conflicts ?? [] + const sid: string | undefined = res.session_id || sessionId || undefined + const sessionChanged = Boolean(sid && sid !== sessionIdFromStore) + if (conflicts.length > 0) { + setSessionId(sid ?? null) + if (sessionChanged) { + clearResolutions() + } + setConflicts(conflicts) + return { + status: 'conflicts', + conflicts, + sessionId: sid, + message: res.message, + } + } + + const finalizeIfNeeded = async (): Promise => { + if (options?.autoFinalize === false) { + // Caller will explicitly finalize; keep session available to them. + return { + status: 'merged', + conflicts: [], + sessionId: sid, + message: res.message, + } + } + if (!sid) { + clearSession() + return { + status: 'merged', + conflicts: [], + sessionId: undefined, + message: res.message, + } + } + + try { + const finalizeRes = await finalizePullSession({ id: sid }) + const msg = finalizeRes.message || res.message || 'Finalize failed' + if (typeof msg === 'string' && msg.toLowerCase().includes('stale')) { + clearAllConflicts() + return { status: 'stale', conflicts: [], sessionId: undefined, message: msg } + } + + if (finalizeRes.success) { + clearSession() + return { + status: 'merged', + conflicts: [], + sessionId: undefined, + message: msg, + } + } + + const remaining = finalizeRes.conflicts ?? [] + if (remaining.length > 0) { + setSessionId(sid ?? null) + setConflicts(remaining) + return { + status: 'conflicts', + conflicts: remaining, + sessionId: sid, + message: finalizeRes.message || res.message, + } + } + + return { + status: 'error', + conflicts: readConflicts(), + sessionId: sid, + message: msg, + } + } catch (err: any) { + // Surface finalize errors so caller can prompt a retry. + const detail = err?.body?.message || err?.message || 'Finalize failed' + return { + status: 'error', + conflicts: readConflicts(), + sessionId: sid, + message: detail, + } + } + } + + return await finalizeIfNeeded() + } catch (e: any) { + const bodyConflicts = extractConflicts(e?.body) + const statusField = (e as any)?.body?.status + const msg = (e as any)?.body?.message || e?.message || `${e}` + + if (statusField === 'stale' || (typeof msg === 'string' && msg.toLowerCase().includes('stale'))) { + clearAllConflicts() + return { status: 'stale', conflicts: [], sessionId: undefined, message: msg } + } + + if (e instanceof ApiError && e.status === 409) { + if (bodyConflicts.length > 0) { + const sid = (e as any)?.body?.session_id || sessionId || readSessionId() || undefined + setSessionId(sid ?? null) + if (!sessionId || sid !== sessionIdFromStore) { + clearResolutions() + } + setConflicts(bodyConflicts) + return { status: 'conflicts', conflicts: bodyConflicts, sessionId: sid, message: msg } + } + clearResolutions() + const fallback = readConflicts() + setConflicts(fallback) + return { + status: 'conflicts', + conflicts: fallback, + sessionId: readSessionId() || undefined, + message: msg || 'Conflicts reported but none returned.', + emptyConflictWarning: true, + } + } + + return { + status: 'error', + conflicts: readConflicts(), + sessionId: readSessionId() || undefined, + message: msg, + } + } +} diff --git a/app/src/features/git-sync/ui/git-pull-dialog.tsx b/app/src/features/git-sync/ui/git-pull-dialog.tsx new file mode 100644 index 00000000..4e1e1202 --- /dev/null +++ b/app/src/features/git-sync/ui/git-pull-dialog.tsx @@ -0,0 +1,120 @@ +import { Link } from '@tanstack/react-router' +import { AlertTriangle, ExternalLink, Loader2 } from 'lucide-react' + +import type { GitPullConflictItem } from '@/shared/api' +import { overlayPanelClass } from '@/shared/lib/overlay-classes' +import { cn } from '@/shared/lib/utils' +import { Button } from '@/shared/ui/button' +import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/shared/ui/dialog' + +type Props = { + open: boolean + onOpenChange: (open: boolean) => void + conflicts: GitPullConflictItem[] + isLoading: boolean + onRetry?: () => void + emptyWarning?: boolean + sessionId?: string | null +} + +export default function GitPullDialog({ open, onOpenChange, conflicts, isLoading, onRetry, emptyWarning, sessionId }: Props) { + return ( + + + + + + Resolve conflicts + + + Remote is ahead. Choose whether to keep your version or the remote version for each file, then apply. + + {sessionId ? ( +
+ Session ID: {sessionId} +
+ ) : null} +
+ +
+ {isLoading ? ( +
+ + Loading conflicts… +
+ ) : conflicts.length === 0 ? ( +
+
+

+ {emptyWarning + ? 'Server reported conflicts but returned no list.' + : 'No conflicts reported.'} +

+ {emptyWarning ? ( +

+ Retry the pull to attempt fetching the conflict list. If it persists, check Git server logs. +

+ ) : null} +
+ {onRetry ? ( + + ) : null} +
+ ) : ( + conflicts.map((conflict) => { + const docId = conflict.document_id + const conflictLink = docId ? { id: docId } : null + return ( +
+
+
{conflict.path}
+ {conflictLink ? ( + + ) : ( + + )} +
+ {!conflict.is_binary ? ( +

+ Text conflict. Open the document to resolve hunks, then apply merge. +

+ ) : ( +

+ Binary file. Only full-file choice is supported. +

+ )} +
+ ) + }) + )} +
+ + + + +
+
+ ) +} diff --git a/app/src/features/git-sync/ui/git-sync-button.tsx b/app/src/features/git-sync/ui/git-sync-button.tsx index c92b4ad8..1813a25f 100644 --- a/app/src/features/git-sync/ui/git-sync-button.tsx +++ b/app/src/features/git-sync/ui/git-sync-button.tsx @@ -1,9 +1,10 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { Link } from '@tanstack/react-router' -import { AlertCircle, CheckCircle, Eye, FileX, GitCommit, History, Loader2, Settings } from 'lucide-react' -import { useMemo, useState, useCallback } from 'react' +import { AlertCircle, CheckCircle, Eye, FileX, GitCommit, History, Loader2, RefreshCw, Settings } from 'lucide-react' +import { useCallback, useEffect, useMemo, useState } from 'react' import { toast } from 'sonner' +import type { GitPullConflictItem, GitPullResolution } from '@/shared/api' import { useIsMobile } from '@/shared/hooks/use-mobile' import { overlayMenuClass } from '@/shared/lib/overlay-classes' import { cn } from '@/shared/lib/utils' @@ -11,10 +12,14 @@ import { Button } from '@/shared/ui/button' import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger } from '@/shared/ui/dropdown-menu' import { Tooltip, TooltipContent, TooltipTrigger } from '@/shared/ui/tooltip' -import { getStatus, getConfig, syncNow, initRepository } from '@/entities/git' +import { getStatus, getConfig, syncNow, initRepository, getPullSession } from '@/entities/git' + +import { clearResolutions, clearSession, readConflicts, readSessionId, setConflicts, setSessionId, subscribeSessionId } from '@/features/git-sync/lib/git-conflict-store' +import { performPullSession } from '@/features/git-sync/lib/pull-session-manager' import GitChangesDialog from './git-changes-dialog' import GitHistoryDialog from './git-history-dialog' +import GitPullDialog from './git-pull-dialog' type Props = { className?: string; compact?: boolean } @@ -26,6 +31,16 @@ function useGitSyncController() { const isMobile = useIsMobile() const [showChanges, setShowChanges] = useState(false) const [showHistory, setShowHistory] = useState(false) + const [showPullDialog, setShowPullDialog] = useState(false) + const [pullConflicts, setPullConflicts] = useState(() => readConflicts()) + const [emptyConflictWarning, setEmptyConflictWarning] = useState(false) + const [polling, setPolling] = useState(false) + const [sessionId, setSessionIdState] = useState(() => readSessionId()) + + useEffect(() => { + const unsubscribe = subscribeSessionId((sid) => setSessionIdState(sid)) + return () => unsubscribe() + }, []) const { data: status, @@ -51,6 +66,15 @@ function useGitSyncController() { toast.error('Git sync failed: repository URL or branch was not found. Please check the URL/branch and try again.') } else if (lower.includes('git_auth_redirect') || lower.includes('too many redirects') || lower.includes('http (34)')) { toast.error('Git sync failed: remote requires re-authentication. Please re-enter your token/SSH key and ensure SSO is approved.') + } else if (e?.status === 409) { + toast.error('Remote is ahead. Pull and resolve conflicts before syncing.') + clearResolutions() + const fallback = readConflicts() + setPullConflicts(fallback) + setConflicts(fallback) + if (!fallback.length) setEmptyConflictWarning(true) + setShowPullDialog(true) + pullMutation.mutate({ resolutions: [] }) } else { toast.error(`Sync failed: ${raw}`) } @@ -67,6 +91,38 @@ function useGitSyncController() { onError: (e: any) => toast.error(`Initialization failed: ${e?.message || e}`), }) + const pullMutation = useMutation({ + mutationFn: async (payload?: { resolutions?: GitPullResolution[] }) => + performPullSession(payload?.resolutions, { sessionId }), + onSuccess: (result) => { + setSessionIdState(result.sessionId ?? null) + setPullConflicts(result.conflicts) + setEmptyConflictWarning(Boolean(result.emptyConflictWarning)) + + if (result.status === 'stale') { + toast.error('Pull session expired. Please pull again.') + qc.invalidateQueries({ queryKey: ['git-status'] }) + return + } + + if (result.status === 'conflicts') { + if (result.emptyConflictWarning) { + toast.error('Conflicts reported but server returned no list.') + } + setShowPullDialog(true) + return + } + + if (result.status === 'merged') { + toast.success('Pull completed') + qc.invalidateQueries({ queryKey: ['git-status'] }) + return + } + + toast.error(result.message || 'Pull failed') + }, + }) + const syncPending = syncMutation.isPending || initMutation.isPending const hasChanges = ((status?.uncommitted_changes || 0) + (status?.untracked_files || 0)) > 0 const isConfigured = Boolean(config) && Boolean(status?.repository_initialized) @@ -121,7 +177,39 @@ function useGitSyncController() { return }, [config, hasChanges, status, statusError, statusLoading, syncPending]) + useEffect(() => { + const sid = sessionId ?? readSessionId() + if (!sid) return + setPolling(true) + const timer = window.setInterval(() => { + getPullSession({ id: sid }) + .then((session) => { + if ((session as any)?.status === 'stale') { + clearSession() + clearResolutions() + setConflicts([]) + setPullConflicts([]) + setEmptyConflictWarning(true) + toast.error('Pull session expired. Please pull again.') + return + } + setSessionId(session.session_id) + const conflicts = session.conflicts ?? [] + setConflicts(conflicts) + setPullConflicts(conflicts) + setEmptyConflictWarning(false) + }) + .catch(() => {}) + }, 10000) + return () => { + window.clearInterval(timer) + setPolling(false) + } + }, [sessionId]) + return { + sessionId, + polling, isMobile, syncPending, canSync, @@ -137,12 +225,21 @@ function useGitSyncController() { setShowHistory, isConfigured, showButton, + showPullDialog, + setShowPullDialog, + pullMutation, + pullConflicts, + setPullConflicts, + setEmptyConflictWarning, + emptyConflictWarning, } } export default function GitSyncButton({ className, compact = false }: Props) { const controller = useGitSyncController() const { + sessionId, + polling, isMobile, syncPending, canSync, @@ -158,6 +255,13 @@ export default function GitSyncButton({ className, compact = false }: Props) { setShowHistory, isConfigured, showButton, + showPullDialog, + setShowPullDialog, + pullMutation, + pullConflicts, + setPullConflicts, + setEmptyConflictWarning, + emptyConflictWarning, } = controller const [menuOpen, setMenuOpen] = useState(false) @@ -196,7 +300,9 @@ export default function GitSyncButton({ className, compact = false }: Props) { {icon}

Git Sync

-

{statusText}

+

+ {polling ? 'Synchronizing conflicts…' : statusText} +

@@ -215,6 +321,26 @@ export default function GitSyncButton({ className, compact = false }: Props) { )} Sync Now + { + clearResolutions() + const stored = readConflicts() + setPullConflicts(stored) + setConflicts(stored) + setEmptyConflictWarning(!stored.length) + setShowPullDialog(true) + pullMutation.mutate({ resolutions: [] }) + setMenuOpen(false) + }} + disabled={!isConfigured || pullMutation.isPending} + > + {pullMutation.isPending ? ( + + ) : ( + + )} + Pull (resolve conflicts) + { openChanges() @@ -251,6 +377,15 @@ export default function GitSyncButton({ className, compact = false }: Props) { + pullMutation.mutate({ resolutions: [] })} + /> ) } diff --git a/app/src/routes/(app)/document/$id.tsx b/app/src/routes/(app)/document/$id.tsx index 32ce785f..784a7c96 100644 --- a/app/src/routes/(app)/document/$id.tsx +++ b/app/src/routes/(app)/document/$id.tsx @@ -12,6 +12,7 @@ import SecondaryViewer from '@/widgets/secondary-viewer/SecondaryViewer' export type DocumentRouteSearch = { token?: string + conflict?: string [key: string]: string | string[] | undefined } @@ -48,9 +49,9 @@ export const Route = createFileRoute('/(app)/document/$id')({ const meta = await fetchDocumentMeta(params.id, token) const title = typeof meta?.title === 'string' ? meta.title.trim() : '' const createdByPlugin = meta?.created_by_plugin ?? null - return { title, token, createdByPlugin } satisfies LoaderData + return { title, token, createdByPlugin, path: meta?.path ?? null, desired_path: meta?.desired_path ?? null } satisfies LoaderData } catch { - return { title: '', token, createdByPlugin: undefined } satisfies LoaderData + return { title: '', token, createdByPlugin: undefined, path: null, desired_path: null } satisfies LoaderData } }, head: ({ loaderData, params }) => { @@ -97,11 +98,13 @@ function DocumentRouteComponent() { const loaderData = Route.useLoaderData() as LoaderData | undefined const search = Route.useSearch() as DocumentRouteSearch const shareToken = loaderData?.token ?? (typeof search.token === 'string' && search.token.trim().length > 0 ? search.token.trim() : undefined) + const conflictMode = Object.prototype.hasOwnProperty.call(search, 'conflict') return ( } /> ) diff --git a/app/src/shared/api/client.config.ts b/app/src/shared/api/client.config.ts index 9da26573..6f28b7a5 100644 --- a/app/src/shared/api/client.config.ts +++ b/app/src/shared/api/client.config.ts @@ -45,7 +45,7 @@ export function setClientWorkspaceId(workspaceId: string | null) { writeSessionWorkspaceId(workspaceId) } -function getClientWorkspaceId() { +export function getClientWorkspaceId() { if (typeof window === 'undefined') { return inMemoryWorkspaceId } diff --git a/app/src/shared/api/client/sdk.gen.ts b/app/src/shared/api/client/sdk.gen.ts index 2f160447..6b9970be 100644 --- a/app/src/shared/api/client/sdk.gen.ts +++ b/app/src/shared/api/client/sdk.gen.ts @@ -3,7 +3,7 @@ import type { CancelablePromise } from './core/CancelablePromise'; import { OpenAPI } from './core/OpenAPI'; import { request as __request } from './core/request'; -import type { LoginData, LoginResponse2, LogoutResponse, MeResponse, DeleteAccountResponse, OauthLoginData, OauthLoginResponse, OauthStateData, OauthStateResponse, ListOauthProvidersResponse, RefreshSessionResponse, RegisterData, RegisterResponse, ListSessionsResponse, RevokeSessionData, RevokeSessionResponse, ListDocumentsData, ListDocumentsResponse, CreateDocumentData, CreateDocumentResponse, SearchDocumentsData, SearchDocumentsResponse, GetDocumentData, GetDocumentResponse, DeleteDocumentData, DeleteDocumentResponse, UpdateDocumentData, UpdateDocumentResponse, ArchiveDocumentData, ArchiveDocumentResponse, GetBacklinksData, GetBacklinksResponse, GetDocumentContentData, GetDocumentContentResponse, UpdateDocumentContentData, UpdateDocumentContentResponse, PatchDocumentContentData, PatchDocumentContentResponse, DownloadDocumentData, DownloadDocumentResponse, DuplicateDocumentData, DuplicateDocumentResponse, GetOutgoingLinksData, GetOutgoingLinksResponse, ListDocumentSnapshotsData, ListDocumentSnapshotsResponse, GetDocumentSnapshotDiffData, GetDocumentSnapshotDiffResponse, DownloadDocumentSnapshotData, DownloadDocumentSnapshotResponse, RestoreDocumentSnapshotData, RestoreDocumentSnapshotResponse, UnarchiveDocumentData, UnarchiveDocumentResponse, UploadFileData, UploadFileResponse2, GetFileByNameData, GetFileByNameResponse, GetFileData, GetFileResponse, GetChangesResponse, GetConfigResponse, CreateOrUpdateConfigData, CreateOrUpdateConfigResponse, DeleteConfigResponse, DeinitRepositoryResponse, GetCommitDiffData, GetCommitDiffResponse, GetWorkingDiffResponse, CheckPathIgnoredData, CheckPathIgnoredResponse, GetGitignorePatternsResponse, AddGitignorePatternsData, AddGitignorePatternsResponse, GetHistoryResponse, IgnoreDocumentData, IgnoreDocumentResponse, IgnoreFolderData, IgnoreFolderResponse, InitRepositoryResponse, GetStatusResponse, SyncNowData, SyncNowResponse, HealthResponse, RenderMarkdownData, RenderMarkdownResponse, RenderMarkdownManyData, RenderMarkdownManyResponse, ListApiTokensResponse, CreateApiTokenData, CreateApiTokenResponse, RevokeApiTokenData, RevokeApiTokenResponse, PluginsInstallFromUrlData, PluginsInstallFromUrlResponse, PluginsGetManifestResponse, PluginsUninstallData, PluginsUninstallResponse, SseUpdatesResponse, GetUserShortcutsResponse, UpdateUserShortcutsData, UpdateUserShortcutsResponse, PluginsGetKvData, PluginsGetKvResponse, PluginsPutKvData, PluginsPutKvResponse, ListRecordsData, ListRecordsResponse, PluginsCreateRecordData, PluginsCreateRecordResponse, PluginsExecActionData, PluginsExecActionResponse, PluginsDeleteRecordData, PluginsDeleteRecordResponse, PluginsUpdateRecordData, PluginsUpdateRecordResponse, GetPublishStatusData, GetPublishStatusResponse, PublishDocumentData, PublishDocumentResponse, UnpublishDocumentData, UnpublishDocumentResponse, ListWorkspacePublicDocumentsData, ListWorkspacePublicDocumentsResponse, GetPublicByWorkspaceAndIdData, GetPublicByWorkspaceAndIdResponse, GetPublicContentByWorkspaceAndIdData, GetPublicContentByWorkspaceAndIdResponse, CreateShareData, CreateShareResponse2, ListActiveSharesResponse, ListApplicableSharesData, ListApplicableSharesResponse, BrowseShareData, BrowseShareResponse, ListDocumentSharesData, ListDocumentSharesResponse, MaterializeFolderShareData, MaterializeFolderShareResponse, ListShareMountsResponse, CreateShareMountData, CreateShareMountResponse, DeleteShareMountData, DeleteShareMountResponse, ValidateShareTokenData, ValidateShareTokenResponse, DeleteShareData, DeleteShareResponse, ListTagsData, ListTagsResponse, AcceptInvitationData, AcceptInvitationResponse, ListWorkspacesResponse, CreateWorkspaceData, CreateWorkspaceResponse, GetWorkspaceDetailData, GetWorkspaceDetailResponse, UpdateWorkspaceData, UpdateWorkspaceResponse, DeleteWorkspaceData, DeleteWorkspaceResponse, DownloadWorkspaceArchiveData, DownloadWorkspaceArchiveResponse, ListInvitationsData, ListInvitationsResponse, CreateInvitationData, CreateInvitationResponse, RevokeInvitationData, RevokeInvitationResponse, ListMembersData, ListMembersResponse, RemoveMemberData, RemoveMemberResponse, UpdateMemberRoleData, UpdateMemberRoleResponse, GetWorkspacePermissionsData, GetWorkspacePermissionsResponse, ListRolesData, ListRolesResponse, CreateRoleData, CreateRoleResponse, DeleteRoleData, DeleteRoleResponse, UpdateRoleData, UpdateRoleResponse, SwitchWorkspaceData, SwitchWorkspaceResponse2, AxumWsEntryData } from './types.gen'; +import type { LoginData, LoginResponse2, LogoutResponse, MeResponse, DeleteAccountResponse, OauthLoginData, OauthLoginResponse, OauthStateData, OauthStateResponse, ListOauthProvidersResponse, RefreshSessionResponse, RegisterData, RegisterResponse, ListSessionsResponse, RevokeSessionData, RevokeSessionResponse, ListDocumentsData, ListDocumentsResponse, CreateDocumentData, CreateDocumentResponse, SearchDocumentsData, SearchDocumentsResponse, GetDocumentData, GetDocumentResponse, DeleteDocumentData, DeleteDocumentResponse, UpdateDocumentData, UpdateDocumentResponse, ArchiveDocumentData, ArchiveDocumentResponse, GetBacklinksData, GetBacklinksResponse, GetDocumentContentData, GetDocumentContentResponse, UpdateDocumentContentData, UpdateDocumentContentResponse, PatchDocumentContentData, PatchDocumentContentResponse, DownloadDocumentData, DownloadDocumentResponse, DuplicateDocumentData, DuplicateDocumentResponse, GetOutgoingLinksData, GetOutgoingLinksResponse, ListDocumentSnapshotsData, ListDocumentSnapshotsResponse, GetDocumentSnapshotDiffData, GetDocumentSnapshotDiffResponse, DownloadDocumentSnapshotData, DownloadDocumentSnapshotResponse, RestoreDocumentSnapshotData, RestoreDocumentSnapshotResponse, UnarchiveDocumentData, UnarchiveDocumentResponse, UploadFileData, UploadFileResponse2, GetFileByNameData, GetFileByNameResponse, GetFileData, GetFileResponse, GetChangesResponse, GetConfigResponse, CreateOrUpdateConfigData, CreateOrUpdateConfigResponse, DeleteConfigResponse, DeinitRepositoryResponse, GetCommitDiffData, GetCommitDiffResponse, GetWorkingDiffResponse, CheckPathIgnoredData, CheckPathIgnoredResponse, GetGitignorePatternsResponse, AddGitignorePatternsData, AddGitignorePatternsResponse, GetHistoryResponse, IgnoreDocumentData, IgnoreDocumentResponse, IgnoreFolderData, IgnoreFolderResponse, ImportRepositoryData, ImportRepositoryResponse, InitRepositoryResponse, PullRepositoryData, PullRepositoryResponse, GetPullSessionData, GetPullSessionResponse, FinalizePullSessionData, FinalizePullSessionResponse, ResolvePullSessionData, ResolvePullSessionResponse, StartPullSessionResponse, GetStatusResponse, SyncNowData, SyncNowResponse, HealthResponse, RenderMarkdownData, RenderMarkdownResponse, RenderMarkdownManyData, RenderMarkdownManyResponse, ListApiTokensResponse, CreateApiTokenData, CreateApiTokenResponse, RevokeApiTokenData, RevokeApiTokenResponse, PluginsInstallFromUrlData, PluginsInstallFromUrlResponse, PluginsGetManifestResponse, PluginsUninstallData, PluginsUninstallResponse, SseUpdatesResponse, GetUserShortcutsResponse, UpdateUserShortcutsData, UpdateUserShortcutsResponse, PluginsGetKvData, PluginsGetKvResponse, PluginsPutKvData, PluginsPutKvResponse, ListRecordsData, ListRecordsResponse, PluginsCreateRecordData, PluginsCreateRecordResponse, PluginsExecActionData, PluginsExecActionResponse, PluginsDeleteRecordData, PluginsDeleteRecordResponse, PluginsUpdateRecordData, PluginsUpdateRecordResponse, GetPublishStatusData, GetPublishStatusResponse, PublishDocumentData, PublishDocumentResponse, UnpublishDocumentData, UnpublishDocumentResponse, ListWorkspacePublicDocumentsData, ListWorkspacePublicDocumentsResponse, GetPublicByWorkspaceAndIdData, GetPublicByWorkspaceAndIdResponse, GetPublicContentByWorkspaceAndIdData, GetPublicContentByWorkspaceAndIdResponse, CreateShareData, CreateShareResponse2, ListActiveSharesResponse, ListApplicableSharesData, ListApplicableSharesResponse, BrowseShareData, BrowseShareResponse, ListDocumentSharesData, ListDocumentSharesResponse, MaterializeFolderShareData, MaterializeFolderShareResponse, ListShareMountsResponse, CreateShareMountData, CreateShareMountResponse, DeleteShareMountData, DeleteShareMountResponse, ValidateShareTokenData, ValidateShareTokenResponse, DeleteShareData, DeleteShareResponse, ListTagsData, ListTagsResponse, AcceptInvitationData, AcceptInvitationResponse, ListWorkspacesResponse, CreateWorkspaceData, CreateWorkspaceResponse, GetWorkspaceDetailData, GetWorkspaceDetailResponse, UpdateWorkspaceData, UpdateWorkspaceResponse, DeleteWorkspaceData, DeleteWorkspaceResponse, DownloadWorkspaceArchiveData, DownloadWorkspaceArchiveResponse, ListInvitationsData, ListInvitationsResponse, CreateInvitationData, CreateInvitationResponse, RevokeInvitationData, RevokeInvitationResponse, ListMembersData, ListMembersResponse, RemoveMemberData, RemoveMemberResponse, UpdateMemberRoleData, UpdateMemberRoleResponse, GetWorkspacePermissionsData, GetWorkspacePermissionsResponse, ListRolesData, ListRolesResponse, CreateRoleData, CreateRoleResponse, DeleteRoleData, DeleteRoleResponse, UpdateRoleData, UpdateRoleResponse, SwitchWorkspaceData, SwitchWorkspaceResponse2, AxumWsEntryData } from './types.gen'; /** * @param data The data for the request. @@ -764,6 +764,21 @@ export const ignoreFolder = (data: IgnoreFolderData): CancelablePromise => { + return __request(OpenAPI, { + method: 'POST', + url: '/api/git/import', + body: data.requestBody, + mediaType: 'application/json' + }); +}; + /** * @returns unknown OK * @throws ApiError @@ -775,6 +790,98 @@ export const initRepository = (): CancelablePromise => { }); }; +/** + * @param data The data for the request. + * @param data.requestBody + * @returns GitPullResponse + * @throws ApiError + */ +export const pullRepository = (data: PullRepositoryData): CancelablePromise => { + return __request(OpenAPI, { + method: 'POST', + url: '/api/git/pull', + body: data.requestBody, + mediaType: 'application/json', + errors: { + 409: 'Conflicts detected' + } + }); +}; + +/** + * @param data The data for the request. + * @param data.id + * @returns GitPullSessionResponse + * @throws ApiError + */ +export const getPullSession = (data: GetPullSessionData): CancelablePromise => { + return __request(OpenAPI, { + method: 'GET', + url: '/api/git/pull/session/{id}', + path: { + id: data.id + } + }); +}; + +/** + * @param data The data for the request. + * @param data.id + * @returns GitPullResponse + * @throws ApiError + */ +export const finalizePullSession = (data: FinalizePullSessionData): CancelablePromise => { + return __request(OpenAPI, { + method: 'POST', + url: '/api/git/pull/session/{id}/finalize', + path: { + id: data.id + }, + errors: { + 400: '', + 409: '' + } + }); +}; + +/** + * @param data The data for the request. + * @param data.id + * @param data.requestBody + * @returns GitPullSessionResponse + * @throws ApiError + */ +export const resolvePullSession = (data: ResolvePullSessionData): CancelablePromise => { + return __request(OpenAPI, { + method: 'POST', + url: '/api/git/pull/session/{id}/resolve', + path: { + id: data.id + }, + body: data.requestBody, + mediaType: 'application/json', + errors: { + 400: '', + 409: '' + } + }); +}; + +/** + * @returns GitPullSessionResponse + * @throws ApiError + */ +export const startPullSession = (): CancelablePromise => { + return __request(OpenAPI, { + method: 'POST', + url: '/api/git/pull/start', + errors: { + 400: '', + 409: 'Conflicts detected' + } + }); +}; + /** * @returns GitStatus * @throws ApiError diff --git a/app/src/shared/api/client/types.gen.ts b/app/src/shared/api/client/types.gen.ts index b860e40f..72317753 100644 --- a/app/src/shared/api/client/types.gen.ts +++ b/app/src/shared/api/client/types.gen.ts @@ -245,6 +245,51 @@ export type GitHistoryResponse = { commits: Array; }; +export type GitImportResponse = { + attachments_created: number; + commit_hash?: (string) | null; + docs_created: number; + files_changed: number; + message: string; + success: boolean; +}; + +export type GitPullConflictItem = { + base?: (string) | null; + document_id?: (string) | null; + is_binary: boolean; + ours?: (string) | null; + path: string; + theirs?: (string) | null; +}; + +export type GitPullRequest = { + resolutions?: Array | null; +}; + +export type GitPullResolution = { + choice: string; + content?: (string) | null; + path: string; +}; + +export type GitPullResponse = { + commit_hash?: (string) | null; + conflicts?: Array | null; + files_changed: number; + git_status?: ((GitStatus) | null); + message: string; + success: boolean; +}; + +export type GitPullSessionResponse = { + conflicts: Array; + message?: (string) | null; + resolutions: Array; + session_id: string; + status: string; +}; + export type GitRemoteCheckResponse = { message: string; ok: boolean; @@ -1101,8 +1146,41 @@ export type IgnoreFolderData = { export type IgnoreFolderResponse = (unknown); +export type ImportRepositoryData = { + requestBody: CreateGitConfigRequest; +}; + +export type ImportRepositoryResponse = (GitImportResponse); + export type InitRepositoryResponse = (unknown); +export type PullRepositoryData = { + requestBody: GitPullRequest; +}; + +export type PullRepositoryResponse = (GitPullResponse); + +export type GetPullSessionData = { + id: string; +}; + +export type GetPullSessionResponse = (GitPullSessionResponse); + +export type FinalizePullSessionData = { + id: string; +}; + +export type FinalizePullSessionResponse = (GitPullResponse); + +export type ResolvePullSessionData = { + id: string; + requestBody: GitPullRequest; +}; + +export type ResolvePullSessionResponse = (GitPullSessionResponse); + +export type StartPullSessionResponse = (GitPullSessionResponse); + export type GetStatusResponse = (GitStatus); export type SyncNowData = { diff --git a/app/src/styles.css b/app/src/styles.css index d262a472..89337e97 100644 --- a/app/src/styles.css +++ b/app/src/styles.css @@ -381,3 +381,10 @@ transform: translateY(-6px); /* slight lift to clear editor padding */ } } + +/* Remove focus outlines inside conflict diff viewer (Monaco defaults to a blue outline) */ +.conflict-diff .monaco-diff-editor *:focus, +.conflict-diff .monaco-diff-editor .synthetic-focus, +.conflict-diff .monaco-editor { + outline: none !important; +} diff --git a/app/src/widgets/document/DocumentPage.tsx b/app/src/widgets/document/DocumentPage.tsx index aa7b494d..1762f6c6 100644 --- a/app/src/widgets/document/DocumentPage.tsx +++ b/app/src/widgets/document/DocumentPage.tsx @@ -1,14 +1,16 @@ -import { useQueryClient } from '@tanstack/react-query' +import { useMutation, useQueryClient } from '@tanstack/react-query' import { useNavigate } from '@tanstack/react-router' import { BookmarkPlus, Download, History } from 'lucide-react' -import { useCallback, useEffect, useMemo, useState, type ReactNode } from 'react' +import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from 'react' import { toast } from 'sonner' -import { ApiError } from '@/shared/api' +import { ApiError, type GitPullConflictItem, type GitPullResolution } from '@/shared/api' import { useRealtime } from '@/shared/contexts/realtime-context' import type { DocumentHeaderAction } from '@/shared/types/document' +import { Button } from '@/shared/ui/button' import { downloadDocumentFile, type DocumentDownloadFormat } from '@/entities/document' +import { getPullSession } from '@/entities/git' import { createShareMount, shareMountsQuery } from '@/entities/share' import { useAuthContext } from '@/features/auth' @@ -20,16 +22,19 @@ import { } from '@/features/document-download' import { SnapshotHistoryDialog } from '@/features/document-snapshots' import { EditorOverlay, MarkdownEditor, useCollaborativeDocument, useViewContext } from '@/features/edit-document' +import { setConflicts as setGlobalConflicts, readResolutions, setResolutions, clearResolutions, readSessionId, setSessionId, clearSession, readConflicts, subscribeSessionId } from '@/features/git-sync/lib/git-conflict-store' +import { performPullSession } from '@/features/git-sync/lib/pull-session-manager' import { usePluginDocumentRedirect } from '@/features/plugins' import { useSecondaryViewer } from '@/features/secondary-viewer' - type SecondaryViewerType = ReturnType['secondaryDocumentType'] export type DocumentLoaderData = { title: string token?: string createdByPlugin?: string | null + path?: string | null + desired_path?: string | null } export type SecondaryViewerRendererProps = { @@ -45,9 +50,173 @@ export type DocumentPageProps = { loaderData?: DocumentLoaderData shareToken?: string secondaryViewerRenderer?: (props: SecondaryViewerRendererProps) => ReactNode + conflictMode?: boolean +} + +const normalizeConflictPath = (path?: string | null) => (path || '').replace(/^[./]+/, '').trim().toLowerCase() + +type ConflictHunk = { + id: string + ours: string[] + theirs: string[] + oursStart?: number + theirsStart?: number +} + +type ConflictSegments = Array< + | { type: 'equal'; lines: string[] } + | { type: 'conflict'; hunkId: string; ours: string[]; theirs: string[] } +> + +const genHunkId = () => { + if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') return crypto.randomUUID() + return Math.random().toString(36).slice(2) +} + +const buildLineDiffSegments = (oursRaw: string, theirsRaw: string): { segments: ConflictSegments; hunks: ConflictHunk[] } => { + const ours = oursRaw.split('\n') + const theirs = theirsRaw.split('\n') + const m = ours.length + const n = theirs.length + const lcs: number[][] = Array.from({ length: m + 1 }, () => Array(n + 1).fill(0)) + for (let i = m - 1; i >= 0; i -= 1) { + for (let j = n - 1; j >= 0; j -= 1) { + if (ours[i] === theirs[j]) lcs[i][j] = lcs[i + 1][j + 1] + 1 + else lcs[i][j] = Math.max(lcs[i + 1][j], lcs[i][j + 1]) + } + } + + type Op = { type: 'equal' | 'del' | 'ins'; line: string } + const ops: Op[] = [] + let i = 0 + let j = 0 + while (i < m && j < n) { + if (ours[i] === theirs[j]) { + ops.push({ type: 'equal', line: ours[i] }) + i += 1 + j += 1 + } else if (lcs[i + 1][j] >= lcs[i][j + 1]) { + ops.push({ type: 'del', line: ours[i] }) + i += 1 + } else { + ops.push({ type: 'ins', line: theirs[j] }) + j += 1 + } + } + while (i < m) { + ops.push({ type: 'del', line: ours[i] }) + i += 1 + } + while (j < n) { + ops.push({ type: 'ins', line: theirs[j] }) + j += 1 + } + + const segments: ConflictSegments = [] + const hunks: ConflictHunk[] = [] + let currentEqual: string[] = [] + let currentConflict: { ours: string[]; theirs: string[]; hunkId: string } | null = null + + const pushEqual = () => { + if (currentEqual.length) { + segments.push({ type: 'equal', lines: currentEqual }) + currentEqual = [] + } + } + + const pushConflict = () => { + if (currentConflict) { + segments.push({ type: 'conflict', hunkId: currentConflict.hunkId, ours: currentConflict.ours, theirs: currentConflict.theirs }) + hunks.push({ id: currentConflict.hunkId, ours: currentConflict.ours.slice(), theirs: currentConflict.theirs.slice() }) + currentConflict = null + } + } + + for (const op of ops) { + if (op.type === 'equal') { + pushConflict() + currentEqual.push(op.line) + } else { + pushEqual() + if (!currentConflict) { + currentConflict = { ours: [], theirs: [], hunkId: genHunkId() } + } + if (op.type === 'del') currentConflict.ours.push(op.line) + else currentConflict.theirs.push(op.line) + } + } + pushConflict() + pushEqual() + + return { segments, hunks } } -export function DocumentPage({ id, loaderData, shareToken, secondaryViewerRenderer }: DocumentPageProps) { +const buildMergedText = ( + segments: ConflictSegments, + choices: Record, + defaultPick: 'ours' | 'theirs' = 'ours', +) => { + const out: string[] = [] + for (const seg of segments) { + if (seg.type === 'equal') { + out.push(...seg.lines) + } else { + const pick = choices[seg.hunkId] || defaultPick + out.push(...(pick === 'theirs' ? seg.theirs : seg.ours)) + } + } + return out.join('\n') +} + +const buildHunkAnchors = ( + segments: ConflictSegments, + choices: Record, + defaultPick: 'ours' | 'theirs', +): Array<{ hunkId: string; line: number }> => { + const anchors: Array<{ hunkId: string; line: number }> = [] + let line = 0 + for (const seg of segments) { + if (seg.type === 'equal') { + line += seg.lines.length + } else { + const pick = choices[seg.hunkId] || defaultPick + const lines = pick === 'theirs' ? seg.theirs : seg.ours + const start = line + 1 + const end = line + lines.length + anchors.push({ hunkId: seg.hunkId, line: lines.length ? end : start }) + line = end + } + } + return anchors +} + +const matchConflictToDoc = ( + conflicts: GitPullConflictItem[], + docPaths: Array, + docId: string, +): GitPullConflictItem | null => { + if (conflicts.length === 0) return null + const targets = docPaths + .map((p) => normalizeConflictPath(p)) + .filter((p) => p.length > 0) + + for (const conflict of conflicts) { + if (conflict.document_id && conflict.document_id === docId) return conflict + } + + for (const conflict of conflicts) { + const candidate = normalizeConflictPath(conflict.path) + if (!candidate) continue + if (targets.some((t) => candidate === t || candidate.endsWith(`/${t}`))) { + return conflict + } + } + + if (conflicts.length === 1) return conflicts[0] + return null +} + +export function DocumentPage({ id, loaderData, shareToken, secondaryViewerRenderer, conflictMode = false }: DocumentPageProps) { const [isClient, setIsClient] = useState(typeof window !== 'undefined') useEffect(() => { @@ -64,6 +233,7 @@ export function DocumentPage({ id, loaderData, shareToken, secondaryViewerRender loaderData={loaderData} shareToken={shareToken} secondaryViewerRenderer={secondaryViewerRenderer} + conflictMode={conflictMode} /> ) } @@ -73,7 +243,7 @@ export default DocumentPage function DocumentSSRPlaceholder() { return (
- +
) } @@ -83,6 +253,7 @@ function DocumentClient({ loaderData, shareToken, secondaryViewerRenderer, + conflictMode = false, }: DocumentPageProps) { const navigate = useNavigate() const qc = useQueryClient() @@ -92,11 +263,26 @@ function DocumentClient({ const [showDownloadDialog, setShowDownloadDialog] = useState(false) const [downloadPending, setDownloadPending] = useState(false) const [savingShare, setSavingShare] = useState(false) + const [activeConflict, setActiveConflict] = useState(null) + const [modifiedText, setModifiedText] = useState('') + const [previewContent, setPreviewContent] = useState('') + const [hasInteracted, setHasInteracted] = useState(false) + const [segments, setSegments] = useState([]) + const [hunks, setHunks] = useState([]) + const [hunkChoices, setHunkChoices] = useState>({}) + const [hunkDefaultSide, setHunkDefaultSide] = useState<'ours' | 'theirs'>('ours') + const [hunkAnchors, setHunkAnchors] = useState>([]) + const lastPayloadRef = useRef([]) const { secondaryDocumentId, secondaryDocumentType, showSecondaryViewer, closeSecondaryViewer, openSecondaryViewer } = useSecondaryViewer() const { showBacklinks, setShowBacklinks } = useViewContext() const { status, doc, awareness, isReadOnly, error: realtimeError } = useCollaborativeDocument(id, shareToken) const { documentTitle: realtimeTitle, documentActions, setDocumentActions } = useRealtime() const hasDoc = Boolean(doc) + const [sessionId, setSessionIdState] = useState(() => readSessionId()) + useEffect(() => { + const unsubscribe = subscribeSessionId((sid) => setSessionIdState(sid)) + return () => unsubscribe() + }, []) const pluginRedirectEnabled = loaderData?.createdByPlugin === undefined ? true : Boolean(loaderData?.createdByPlugin) const { redirecting, resolving: pluginResolving } = usePluginDocumentRedirect(id, { @@ -126,6 +312,82 @@ function DocumentClient({ const loaderTitle = loaderData?.title const resolvedTitle = (realtimeTitle && realtimeTitle.trim()) || loaderTitle + const setConflictsForDoc = useCallback( + (list: GitPullConflictItem[]) => { + const safeList = Array.isArray(list) ? list : [] + const existing = readConflicts() + const unchanged = + existing.length === safeList.length && + existing.every((item, idx) => JSON.stringify(item) === JSON.stringify(safeList[idx])) + if (!unchanged) { + setGlobalConflicts(safeList) + } + const matched = matchConflictToDoc(safeList, [loaderData?.path, loaderData?.desired_path], id) + setActiveConflict(matched) + if (matched && !matched.is_binary) { + const oursText = matched.ours ?? '' + const theirsText = matched.theirs ?? '' + const { segments: segs, hunks: nextHunks } = buildLineDiffSegments(oursText, theirsText) + setSegments(segs) + setHunks(nextHunks) + setHunkChoices({}) + setHunkDefaultSide('ours') + // Default merge is ours, but show diff against remote by setting modified to theirs initially. + setModifiedText(theirsText || oursText) + setHunkAnchors(buildHunkAnchors(segs, {}, 'ours')) + setPreviewContent(oursText) + setHasInteracted(false) + } else { + setSegments([]) + setHunks([]) + setHunkChoices({}) + setHunkDefaultSide('ours') + setModifiedText(matched?.theirs ?? matched?.ours ?? '') + setHunkAnchors([]) + setPreviewContent('') + setHasInteracted(false) + } + }, + [loaderData?.desired_path, loaderData?.path], + ) + + useEffect(() => { + if (!conflictMode) return + const fetchConflicts = async () => { + try { + if (sessionId) { + const session = await getPullSession({ id: sessionId }) + if ((session as any)?.status === 'stale') { + clearSession() + clearResolutions() + lastPayloadRef.current = [] + setConflictsForDoc([]) + toast.error('Pull session expired. Please pull again.') + return + } + setSessionId(session.session_id) + setConflictsForDoc(session.conflicts ?? []) + setResolutions(session.resolutions ?? []) + return + } + // Fallback: hydrate from local store when session is not yet available. + setConflictsForDoc(readConflicts()) + } catch (error) { + toast.error((error as any)?.body?.message || (error as any)?.message || 'Failed to load conflicts') + } + } + void fetchConflicts() + }, [conflictMode, setConflictsForDoc, sessionId]) + + useEffect(() => { + if (!segments.length) return + if (hasInteracted) { + setModifiedText(buildMergedText(segments, hunkChoices, hunkDefaultSide)) + } + setHunkAnchors(buildHunkAnchors(segments, hunkChoices, hunkDefaultSide)) + setPreviewContent(buildMergedText(segments, hunkChoices, hunkDefaultSide)) + }, [segments, hunkChoices, hunkDefaultSide, hasInteracted]) + const openDownloadDialog = useCallback(() => { if (!hasDoc) return setShowDownloadDialog(true) @@ -174,6 +436,75 @@ function DocumentClient({ } }, [qc, savingShare, shareToken]) + const handleConflictResolved = useCallback(() => { + navigate({ + to: '/document/$id', + params: { id }, + search: (prev: Record) => { + const next: Record = { ...prev } + delete next.conflict + return next + }, + replace: true, + }) + setConflictsForDoc([]) + clearResolutions() + clearSession() + lastPayloadRef.current = [] + qc.invalidateQueries({ queryKey: ['git-status'] }) + }, [id, navigate, qc]) + + const pullMutation = useMutation({ + mutationFn: async (resolutions: GitPullResolution[]) => + performPullSession(resolutions, { sessionId }), + onSuccess: (result) => { + setSessionIdState(result.sessionId ?? null) + setConflictsForDoc(result.conflicts) + + if (result.status === 'stale') { + clearSession() + clearResolutions() + lastPayloadRef.current = [] + toast.error('Pull session expired. Please pull again.') + return + } + + const stillPending = matchConflictToDoc(result.conflicts, [loaderData?.path, loaderData?.desired_path], id) + if (result.status === 'conflicts') { + const payload = lastPayloadRef.current || [] + if (payload.length) setResolutions(payload) + const message = + result.message || + (stillPending + ? 'Resolution applied. Another conflict remains for this document.' + : 'Resolution applied. Another conflict remains.') + toast.success(message) + return + } + + if (result.status === 'merged') { + clearResolutions() + lastPayloadRef.current = [] + handleConflictResolved() + toast.success(result.message || 'Conflict resolved') + return + } + + toast.error(result.message || 'Failed to apply resolution') + }, + }) + + const submitResolution = useCallback( + (resolution: GitPullResolution) => { + const preserved = readResolutions().filter((r) => r.path !== resolution.path) + const payload = [...preserved, resolution] + setResolutions(payload) + lastPayloadRef.current = payload + pullMutation.mutate(payload) + }, + [pullMutation], + ) + useEffect(() => { const ensureAction = ( list: DocumentHeaderAction[], @@ -242,12 +573,14 @@ function DocumentClient({ const overlayLabel = realtimeError ? realtimeError : pluginResolving - ? 'Preparing plugin…' + ? 'Preparing plugin...' : redirecting - ? 'Opening plugin…' + ? 'Opening plugin...' : status === 'connecting' - ? 'Connecting…' - : 'Loading…' + ? 'Connecting...' + : 'Loading...' + const showEditor = Boolean(doc && awareness && !realtimeError) + const showOverlay = shouldShowOverlay useEffect(() => { if (typeof document === 'undefined') return @@ -256,11 +589,11 @@ function DocumentClient({ const computedTitle = (() => { if (!baseTitle) return 'RefMD' if (shareToken) return baseTitle - return `${baseTitle} • RefMD` + return `${baseTitle} - RefMD` })() document.title = computedTitle - const summary = (() => { + const summary = (() => { if (!baseTitle) return shareToken ? 'Shared document on RefMD' : 'Editing a document on RefMD' if (shareToken) return baseTitle return `${baseTitle} on RefMD` @@ -305,20 +638,163 @@ function DocumentClient({ const renderSecondaryViewer = secondaryViewerRenderer + const oursText = activeConflict?.ours ?? '' + const theirsText = activeConflict?.theirs ?? '' + const isBinaryConflict = activeConflict?.is_binary ?? false + + const hunkCount = useMemo(() => hunks.length, [hunks]) + const resolvedHunks = useMemo(() => hunks.filter((h) => hunkChoices[h.id]).length, [hunks, hunkChoices]) + const allResolved = useMemo(() => (hunkCount ? resolvedHunks === hunkCount : true), [hunkCount, resolvedHunks]) + + const chooseHunkSide = useCallback((hunkId: string, side: 'ours' | 'theirs') => { + setHunkChoices((prev) => ({ ...prev, [hunkId]: side })) + }, []) + + const setAllHunks = useCallback( + (side: 'ours' | 'theirs') => { + if (!hunks.length) return + const entries = Object.fromEntries(hunks.map((h) => [h.id, side])) + setHunkChoices(entries) + setHunkDefaultSide(side) + }, + [hunks], + ) + + const applyGlobalChoice = useCallback( + (side: 'ours' | 'theirs') => { + setAllHunks(side) + const nextText = side === 'theirs' ? theirsText : oursText + setModifiedText(nextText) + setPreviewContent(nextText) + }, + [oursText, setAllHunks, setModifiedText, setPreviewContent, theirsText], + ) + + const handleApplyResolution = useCallback( + (choice: GitPullResolution['choice'], customContent?: string) => { + if (!activeConflict) return + if (choice === 'custom_text' && !allResolved) { + toast.error('Resolve all hunks before applying.') + return + } + if (choice === 'custom_text' && !(customContent ?? modifiedText).trim()) { + toast.error('Add your merged content before applying.') + return + } + const resolution: GitPullResolution = { + path: activeConflict.path, + choice, + content: choice === 'custom_text' ? customContent ?? modifiedText : undefined, + } + submitResolution(resolution) + }, + [activeConflict, allResolved, modifiedText, submitResolution], + ) + + const showConflictUI = Boolean(activeConflict) + + const conflictView = showConflictUI && activeConflict + ? { + kind: isBinaryConflict ? 'binary' as const : 'text' as const, + original: oursText, + modified: modifiedText, + onChange: (val: string) => { + setHasInteracted(true) + setModifiedText(val) + setPreviewContent(val) + }, + readOnly: pullMutation.isPending, + actions: !isBinaryConflict + ? { + onKeepMine: () => { + setHasInteracted(true) + setAllHunks('ours') + setModifiedText(oursText) + setPreviewContent(oursText) + }, + onTakeTheirs: () => { + setHasInteracted(true) + setAllHunks('theirs') + setModifiedText(theirsText) + setPreviewContent(theirsText) + }, + onApplyMerged: () => { + handleApplyResolution('custom_text', modifiedText) + }, + } + : undefined, + } + : undefined + + const conflictControls = showConflictUI + ? ( +
+ {!isBinaryConflict ? ( + <> + + + + + ) : null} +
+ ) + : null + + const conflictBadgeText = !isBinaryConflict ? `${resolvedHunks}/${hunkCount} decided` : undefined + + const conflictHunkWidgets = + showConflictUI && activeConflict && !isBinaryConflict && hunks.length + ? hunkAnchors.map((anchor) => ({ + id: anchor.hunkId, + line: anchor.line, + choice: hunkChoices[anchor.hunkId], + onChoose: (side: 'ours' | 'theirs') => chooseHunkSide(anchor.hunkId, side), + })) + : undefined + return (
- {shouldShowOverlay && } - {doc && awareness && !realtimeError && ( + {showOverlay && } + {showEditor ? ( setShowBacklinks(false)} /> @@ -333,7 +809,7 @@ function DocumentClient({ ) : undefined } /> - )} + ) : null} { if (typeof shareTokenFromContext === 'string' && shareTokenFromContext.length > 0) { diff --git a/app/src/widgets/settings/GitSyncPage.tsx b/app/src/widgets/settings/GitSyncPage.tsx index 3d94052c..539b477c 100644 --- a/app/src/widgets/settings/GitSyncPage.tsx +++ b/app/src/widgets/settings/GitSyncPage.tsx @@ -11,7 +11,6 @@ import { Card } from '@/shared/ui/card' import { Input } from '@/shared/ui/input' import { Label } from '@/shared/ui/label' import { Separator } from '@/shared/ui/separator' -import { Switch } from '@/shared/ui/switch' import { createOrUpdateConfig, @@ -19,7 +18,7 @@ import { getConfig, getStatus, initRepository, - syncNow, + importRepository, } from '@/entities/git' import { settingsNavItems } from '@/features/settings/nav' @@ -38,9 +37,9 @@ export default function GitSyncPage() { const [authType, setAuthType] = React.useState<'ssh' | 'token'>('token') const [token, setToken] = React.useState('') const [privateKey, setPrivateKey] = React.useState('') - const [autoSync, setAutoSync] = React.useState(true) const [lastCheck, setLastCheck] = React.useState(null) const lastSecretRef = React.useRef<{ token?: string; private_key?: string }>({}) + const autoSync = false React.useEffect(() => { if (config) { @@ -49,7 +48,6 @@ export default function GitSyncPage() { setAuthType(config.auth_type === 'ssh' ? 'ssh' : 'token') setToken('') setPrivateKey('') - setAutoSync(config.auto_sync ?? true) setLastCheck((config as any).remote_check ?? null) } }, [config]) @@ -96,6 +94,36 @@ export default function GitSyncPage() { }, }) + const importMutation = useMutation({ + mutationFn: async () => { + if (!repositoryUrl.trim()) throw new Error('Repository URL is required') + const auth_data = resolveAuthData() + return importRepository({ + requestBody: { + repository_url: repositoryUrl.trim(), + branch_name: branchName.trim() || 'main', + auth_type: authType, + auth_data, + auto_sync: autoSync, + }, + }) + }, + onSuccess: (data: any) => { + const msg = data?.message || 'Imported from Git' + const docs = data?.docs_created ?? 0 + const attachments = data?.attachments_created ?? 0 + const extra = + docs || attachments ? ` (${docs} docs, ${attachments} attachments)` : '' + toast.success(`${msg}${extra}`) + qc.invalidateQueries({ queryKey: ['git-status'] }) + qc.invalidateQueries({ queryKey: ['git-config'] }) + }, + onError: (e: any) => { + const raw = e?.body?.message || e?.message || `${e}` + toast.error(`Import failed: ${raw}`) + }, + }) + const initMutation = useMutation({ mutationFn: () => initRepository(), onSuccess: () => { @@ -105,17 +133,6 @@ export default function GitSyncPage() { onError: (e: any) => toast.error(`Initialization failed: ${e?.message || e}`), }) - const syncMutation = useMutation({ - mutationFn: () => syncNow({ requestBody: { message: undefined } }), - onSuccess: (data: any) => { - const changed = data?.files_changed ?? 0 - const msg = data?.message || 'Sync completed' - toast.success(`${msg}: ${changed} files changed`) - qc.invalidateQueries({ queryKey: ['git-status'] }) - }, - onError: (e: any) => toast.error(`Sync failed: ${e?.message || e}`), - }) - const deinitMutation = useMutation({ mutationFn: () => deinitRepository(), onSuccess: () => { @@ -267,13 +284,6 @@ export default function GitSyncPage() {

Leave the secret blank to keep the existing one.

-
-
-

Auto sync

-

Push/pull periodically in background.

-
- -
{authType === 'token' ? ( @@ -306,6 +316,10 @@ export default function GitSyncPage() { +

+ Auto sync is off. Use Pull to fetch remote changes and Sync to push manually. Use Import to populate this workspace from the remote repository. +

+
- {repositoryInitialized ? ( - - ) : null} +
diff --git a/app/src/widgets/sidebar/FileTree.tsx b/app/src/widgets/sidebar/FileTree.tsx index 02a2ad07..b0e18f3d 100644 --- a/app/src/widgets/sidebar/FileTree.tsx +++ b/app/src/widgets/sidebar/FileTree.tsx @@ -4,7 +4,7 @@ import { Archive, Building2, Check, ChevronDown, ChevronRight, FileText, Github, import React, { useCallback, useEffect, useMemo, useState } from 'react' import { toast } from 'sonner' -import type { WorkspaceMembershipResponse } from '@/shared/api' +import type { GitPullConflictItem, WorkspaceMembershipResponse } from '@/shared/api' import { useShortcut } from '@/shared/hooks/use-shortcut' import { overlayMenuClass, overlayPanelClass } from '@/shared/lib/overlay-classes' import { cn } from '@/shared/lib/utils' @@ -18,6 +18,7 @@ import { SidebarHeader, SidebarContent, SidebarFooter, SidebarGroup, SidebarGrou import { Tooltip, TooltipContent, TooltipTrigger } from '@/shared/ui/tooltip' import { downloadWorkspaceArchive } from '@/entities/document' +import { getPullSession } from '@/entities/git' import { useAuthContext } from '@/features/auth' import { useEditorContext } from '@/features/edit-document' @@ -31,6 +32,7 @@ import { useFileTreeDrag } from '@/features/file-tree/lib/useFileTreeDrag' import FileNode from '@/features/file-tree/ui/FileNode' import FolderNode from '@/features/file-tree/ui/FolderNode' import { GitSyncButton } from '@/features/git-sync' +import { GIT_CONFLICT_EVENT, readConflicts, readSessionId, setConflicts as setGlobalConflicts, setSessionId, clearSession, clearResolutions } from '@/features/git-sync/lib/git-conflict-store' import { useSecondaryViewer } from '@/features/secondary-viewer' import { ShareDialog } from '@/features/sharing' import { @@ -265,6 +267,8 @@ function FileTreeInner() { const [temporaryEntries, setTemporaryEntries] = useState([]) const [shareFolderId, setShareFolderId] = useState(null) const [workspaceDownloadPending, setWorkspaceDownloadPending] = useState(false) + const [gitConflicts, setGitConflicts] = useState(() => readConflicts()) + const [sessionId, setSessionIdState] = useState(() => readSessionId()) const openTemporaryDocument = useCallback(() => { if (typeof window === 'undefined') return const entry = createTemporaryDocumentEntry() @@ -276,7 +280,7 @@ function FileTreeInner() { const refreshTempEntries = useCallback(() => { if (typeof window === 'undefined') return [] as TemporaryDocumentMeta[] return listTemporaryDocuments() - }, []) + }, [sessionId]) const clearAllTemporaries = useCallback(() => { const list = refreshTempEntries() list.forEach((entry) => deleteTemporaryDocumentEntry(entry.id)) @@ -340,6 +344,60 @@ function FileTreeInner() { } }, []) + useEffect(() => { + const handler = (event: Event) => { + const detail = (event as CustomEvent)?.detail + if (Array.isArray(detail)) { + setGitConflicts(detail as GitPullConflictItem[]) + } else { + setGitConflicts(readConflicts()) + } + } + const sessionHandler = () => setSessionIdState(readSessionId()) + window.addEventListener(GIT_CONFLICT_EVENT, handler as EventListener) + window.addEventListener('storage', handler) + window.addEventListener('storage', sessionHandler) + return () => { + window.removeEventListener(GIT_CONFLICT_EVENT, handler as EventListener) + window.removeEventListener('storage', handler) + window.removeEventListener('storage', sessionHandler) + } + }, []) + + useEffect(() => { + const sid = sessionId ?? readSessionId() + if (!sid) return + let cancelled = false + const syncSession = () => { + getPullSession({ id: sid }) + .then((session) => { + if (cancelled) return + if ((session as any)?.status === 'stale') { + clearSession() + clearResolutions() + setGitConflicts([]) + return + } + if ((session as any)?.status === 'merged' && (session.conflicts ?? []).length === 0) { + clearSession() + clearResolutions() + setGitConflicts([]) + return + } + setSessionId(session.session_id) + setGlobalConflicts(session.conflicts ?? []) + setGitConflicts(session.conflicts ?? []) + }) + .catch(() => {}) + } + syncSession() + const timer = window.setInterval(syncSession, 10000) + return () => { + cancelled = true + window.clearInterval(timer) + } + }, []) + const { pluginMenu, pluginRules: fileTreeRules, @@ -412,12 +470,11 @@ function FileTreeInner() { await router.navigate({ to: '/document/$id', params: { id: targetId }, - search: (prev: Record) => { - const next = { ...prev } - next.token = node.shareToken - next.shareMount = '1' - return next - }, + search: (prev: Record) => ({ + ...(prev || {}), + token: node.shareToken, + shareMount: '1', + }), }) return } @@ -428,6 +485,42 @@ function FileTreeInner() { await openNode(node) }, [openNode]) + const normalizeConflictPath = useCallback((path?: string | null) => { + if (!path) return '' + return path.replace(/^[./]+/, '').trim().toLowerCase() + }, []) + + const conflictForNode = useCallback( + (node: DocumentNode): GitPullConflictItem | null => { + if (node.type !== 'file') return null + const targets = [normalizeConflictPath(node.path), normalizeConflictPath(node.desiredPath)].filter(Boolean) + const names = new Set() + const addName = (value?: string | null) => { + if (!value) return + const trimmed = value.trim().toLowerCase() + if (trimmed) names.add(trimmed) + } + addName(node.title) + targets.forEach((t) => { + addName(t.split('/').pop()) + }) + if (!targets.length && names.size === 0) return null + for (const conflict of gitConflicts) { + if (conflict.document_id && conflict.document_id === node.id) { + return conflict + } + const candidate = normalizeConflictPath(conflict.path) + if (!candidate) continue + const candidateBase = candidate.split('/').pop() + if (targets.some((t) => candidate === t || candidate.endsWith(`/${t}`)) || (candidateBase && names.has(candidateBase))) { + return conflict + } + } + return null + }, + [gitConflicts, normalizeConflictPath], + ) + // Sync selection from current URL (when user navigates elsewhere) useEffect(() => { const m = pathname.match(/[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}/) @@ -715,6 +808,8 @@ function FileTreeInner() { ) } + const conflict = conflictForNode(node) + return ( ) - }, [createDocument, createFolder, deleteDocument, drag, duplicateDocument, expandedFolders, fileTreeRules, handleDrop, onSelect, openSecondaryViewer, renameDocument, selectedDocId, setShareFolderId, toggleFolder]) + }, [conflictForNode, createDocument, createFolder, deleteDocument, drag, duplicateDocument, expandedFolders, fileTreeRules, handleDrop, onSelect, openSecondaryViewer, renameDocument, selectedDocId, setShareFolderId, toggleFolder]) const renderNestedNode = useCallback((node: DocumentNode, parentId?: string, depth = 1): React.ReactNode => { const isExpanded = expandedFolders.has(node.id) @@ -797,9 +893,10 @@ function FileTreeInner() { pluginRules={fileTreeRules} onOpenSecondaryViewer={openSecondaryViewer} gitEnabled + conflict={conflictForNode(node)} /> ) - }, [createDocument, createFolder, deleteDocument, drag, duplicateDocument, expandedFolders, fileTreeRules, handleDrop, onSelect, openSecondaryViewer, renameDocument, selectedDocId, setShareFolderId, toggleFolder]) + }, [conflictForNode, createDocument, createFolder, deleteDocument, drag, duplicateDocument, expandedFolders, fileTreeRules, handleDrop, onSelect, openSecondaryViewer, renameDocument, selectedDocId, setShareFolderId, toggleFolder]) return (
diff --git a/app/src/widgets/workspaces/WorkspacesPage.tsx b/app/src/widgets/workspaces/WorkspacesPage.tsx index ebfece0c..092a65f1 100644 --- a/app/src/widgets/workspaces/WorkspacesPage.tsx +++ b/app/src/widgets/workspaces/WorkspacesPage.tsx @@ -5,6 +5,7 @@ import { useEffect, useMemo, useState } from 'react' import { toast } from 'sonner' import { ApiError } from '@/shared/api' +import { setClientWorkspaceId } from '@/shared/api/client.config' import { overlayPanelClass } from '@/shared/lib/overlay-classes' import { cn } from '@/shared/lib/utils' import { Badge } from '@/shared/ui/badge' @@ -16,6 +17,7 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@ import { Switch } from '@/shared/ui/switch' import { Textarea } from '@/shared/ui/textarea' +import { importRepository } from '@/entities/git' import { me as meApi, userKeys } from '@/entities/user' import { listWorkspaceInvitations, @@ -102,6 +104,13 @@ export default function WorkspacesPage() { const [createName, setCreateName] = useState('') const [createDescription, setCreateDescription] = useState('') const [creating, setCreating] = useState(false) + const [enableGitImport, setEnableGitImport] = useState(false) + const [importRepoUrl, setImportRepoUrl] = useState('') + const [importBranch, setImportBranch] = useState('main') + const [importAuthType, setImportAuthType] = useState<'token' | 'ssh'>('token') + const [importToken, setImportToken] = useState('') + const [importPrivateKey, setImportPrivateKey] = useState('') + const [importing, setImporting] = useState(false) const [switchingId, setSwitchingId] = useState(null) const [inviteOpen, setInviteOpen] = useState(false) const [inviteEmail, setInviteEmail] = useState('') @@ -263,19 +272,54 @@ export default function WorkspacesPage() { return } setCreating(true) + setImporting(false) try { - await createWorkspaceAction({ name: createName, description: createDescription }) + const workspace = await createWorkspaceAction({ name: createName, description: createDescription }) + const workspaceId = workspace?.id + if (workspaceId) { + setClientWorkspaceId(workspaceId) + } const updated = await meApi() queryClient.setQueryData(userKeys.me(), updated) - toast.success('Workspace created') + if (enableGitImport && importRepoUrl.trim() && workspaceId) { + setImporting(true) + const auth_data = + importAuthType === 'ssh' + ? { private_key: importPrivateKey || undefined } + : { token: importToken || undefined } + try { + const res = await importRepository({ + requestBody: { + repository_url: importRepoUrl.trim(), + branch_name: importBranch.trim() || undefined, + auth_type: importAuthType, + auth_data, + auto_sync: false, + }, + }) + toast.success(res?.message || 'Imported from Git') + } catch (err) { + console.error('[workspaces] git import failed', err) + const raw = (err as any)?.body?.message || (err as any)?.message || 'Git import failed' + toast.error(raw) + } + } else { + toast.success('Workspace created') + } setCreateName('') setCreateDescription('') + setEnableGitImport(false) + setImportRepoUrl('') + setImportBranch('main') + setImportToken('') + setImportPrivateKey('') setCreateOpen(false) } catch (error) { console.error('[workspaces] create failed', error) const message = error instanceof Error ? error.message : 'Failed to create workspace' toast.error(message) } finally { + setImporting(false) setCreating(false) } } @@ -1081,13 +1125,83 @@ export default function WorkspacesPage() { onChange={(event) => setCreateDescription(event.target.value)} />
+
+
+
+

Import from Git (optional)

+

+ Clone documents and attachments from a Git repo into this new workspace. History is preserved. +

+
+ +
+ {enableGitImport ? ( +
+
+ + setImportRepoUrl(event.target.value)} + /> +
+
+
+ + setImportBranch(event.target.value)} + /> +
+
+ + +
+
+ {importAuthType === 'token' ? ( +
+ + setImportToken(event.target.value)} + /> +
+ ) : ( +
+ +