A deployable markdown editor that connects with your self hosted files and lets you edit in a beautiful interface

feat: adds support to create and edit files. Moves publish to be independent of files. Adds discard option

+1432 -1256
+3
.gitignore
··· 77 77 Thumbs.db 78 78 .Spotlight-V100 79 79 .Trashes 80 + 81 + # AI 82 + IMPLEMENTATION_PLAN.md
-1181
IMPLEMENTATION_PLAN.md
··· 1 - # MarkEdit - Implementation Plan 2 - 3 - ## Project Overview 4 - 5 - MarkEdit is a markdown editor designed for managing blog posts stored in various locations (initially GitHub, later Google Drive, Dropbox, etc.). It provides a WYSIWYG editing experience using TipTap, with git-based version control for GitHub repositories. 6 - 7 - ### Core Features (MVP) 8 - - GitHub OAuth authentication 9 - - Repository and file browsing 10 - - Markdown editing with TipTap 11 - - Automatic branch management 12 - - Commit and Pull Request creation 13 - - Docker-based deployment 14 - 15 - ### Explicitly Out of Scope (MVP) 16 - - Image uploads (links only) 17 - - Search functionality 18 - - Version history UI 19 - - Collaborative editing 20 - - Merge conflict resolution (create new branch instead) 21 - - Preview mode 22 - - Keyboard shortcuts 23 - - SSR (Astro runs in client-only mode) 24 - 25 - ## Technical Stack 26 - 27 - ### Backend 28 - - **Language**: Go 1.21+ 29 - - **Router**: `chi` (lightweight, composable) 30 - - **OAuth**: `goth` (multi-provider support) 31 - - **Git Operations**: `go-git` (native Go git implementation) 32 - - **Database**: SQLite with `modernc.org/sqlite` (pure Go, no CGO) 33 - - **Configuration**: `viper` 34 - - **Sessions**: `gorilla/sessions` with encrypted cookies 35 - 36 - ### Frontend 37 - - **Framework**: Astro (client-only, no SSR) 38 - - **UI Library**: React 18 39 - - **UI Components**: shadcn/ui (with Tailwind CSS) 40 - - **Editor**: TipTap (React) 41 - - **Styling**: Tailwind CSS 42 - - **State Management**: `@tanstack/react-query` for server state 43 - - **HTTP Client**: `axios` or `fetch` 44 - - **Build**: Vite (via Astro) 45 - 46 - ### Deployment 47 - - **Container**: Docker (multi-stage build) 48 - - **Base Image**: `alpine` or `scratch` for Go binary 49 - - **Size Target**: < 50MB 50 - 51 - ## Architecture 52 - 53 - ### High-Level Overview 54 - 55 - ``` 56 - ┌─────────────────┐ 57 - │ Astro Frontend │ (Static React App) 58 - │ (Port 3000) │ 59 - └────────┬────────┘ 60 - │ HTTP/REST 61 - 62 - ┌─────────────────┐ 63 - │ Go Backend │ (Single Binary) 64 - │ (Port 8080) │ 65 - └────────┬────────┘ 66 - 67 - ┌────┴────┬──────────┬──────────┐ 68 - ▼ ▼ ▼ ▼ 69 - SQLite GitHub go-git Sessions 70 - ``` 71 - 72 - ### Component Responsibilities 73 - 74 - **Backend (Go):** 75 - - OAuth flow handling 76 - - GitHub API interactions 77 - - Local git operations (clone, branch, commit, push) 78 - - Branch lifecycle management 79 - - File CRUD operations 80 - - Session management 81 - 82 - **Frontend (Astro/React):** 83 - - User interface 84 - - TipTap editor integration 85 - - File tree rendering 86 - - API client 87 - - Local state management 88 - 89 - ### Connector Architecture 90 - 91 - Future-proof design for multiple storage backends: 92 - 93 - ```go 94 - // pkg/connectors/connector.go 95 - type Connector interface { 96 - // Authentication 97 - Authenticate(ctx context.Context, token string) error 98 - 99 - // Repository/Storage listing 100 - ListRepositories(ctx context.Context) ([]Repository, error) 101 - 102 - // File operations 103 - ListFiles(ctx context.Context, repoID, path string) ([]FileNode, error) 104 - ReadFile(ctx context.Context, repoID, path string) (*FileContent, error) 105 - WriteFile(ctx context.Context, repoID, path string, content []byte) error 106 - 107 - // Version control (optional) 108 - CreateBranch(ctx context.Context, repoID, branchName string) error 109 - CommitChanges(ctx context.Context, repoID, branch, message string, files []string) error 110 - PushBranch(ctx context.Context, repoID, branch string) error 111 - CreatePullRequest(ctx context.Context, repoID, branch, title, description string) (*PullRequest, error) 112 - 113 - // Capabilities 114 - SupportsVersionControl() bool 115 - GetType() string // "github", "gdrive", "dropbox" 116 - } 117 - 118 - // Implementations 119 - type GitHubConnector struct { ... } 120 - // Later: GoogleDriveConnector, DropboxConnector 121 - ``` 122 - 123 - ## Database Schema 124 - 125 - ### SQLite Tables 126 - 127 - ```sql 128 - -- Users table (future: when email auth is added) 129 - CREATE TABLE users ( 130 - id INTEGER PRIMARY KEY AUTOINCREMENT, 131 - github_id INTEGER UNIQUE, 132 - username TEXT NOT NULL, 133 - email TEXT, 134 - avatar_url TEXT, 135 - created_at DATETIME DEFAULT CURRENT_TIMESTAMP, 136 - updated_at DATETIME DEFAULT CURRENT_TIMESTAMP 137 - ); 138 - 139 - -- Sessions/Tokens (encrypted access tokens) 140 - CREATE TABLE auth_tokens ( 141 - id INTEGER PRIMARY KEY AUTOINCREMENT, 142 - user_id INTEGER NOT NULL, 143 - provider TEXT NOT NULL, -- "github", "gdrive", etc. 144 - access_token TEXT NOT NULL, -- encrypted 145 - refresh_token TEXT, -- encrypted 146 - expires_at DATETIME, 147 - created_at DATETIME DEFAULT CURRENT_TIMESTAMP, 148 - FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE 149 - ); 150 - 151 - -- Branch state tracking 152 - CREATE TABLE branch_states ( 153 - id INTEGER PRIMARY KEY AUTOINCREMENT, 154 - user_id INTEGER NOT NULL, 155 - repo_full_name TEXT NOT NULL, -- "owner/repo" 156 - branch_name TEXT NOT NULL, 157 - base_branch TEXT DEFAULT 'main', -- PR target 158 - last_push_at DATETIME NOT NULL, 159 - has_uncommitted_changes BOOLEAN DEFAULT FALSE, 160 - file_paths TEXT, -- JSON array of edited files 161 - created_at DATETIME DEFAULT CURRENT_TIMESTAMP, 162 - updated_at DATETIME DEFAULT CURRENT_TIMESTAMP, 163 - UNIQUE(user_id, repo_full_name), 164 - FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE 165 - ); 166 - 167 - -- Work-in-progress content (auto-save buffer) 168 - CREATE TABLE draft_content ( 169 - id INTEGER PRIMARY KEY AUTOINCREMENT, 170 - user_id INTEGER NOT NULL, 171 - repo_full_name TEXT NOT NULL, 172 - file_path TEXT NOT NULL, 173 - content TEXT NOT NULL, 174 - last_saved_at DATETIME DEFAULT CURRENT_TIMESTAMP, 175 - UNIQUE(user_id, repo_full_name, file_path), 176 - FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE 177 - ); 178 - 179 - -- Indexes 180 - CREATE INDEX idx_auth_tokens_user_provider ON auth_tokens(user_id, provider); 181 - CREATE INDEX idx_branch_states_user_repo ON branch_states(user_id, repo_full_name); 182 - CREATE INDEX idx_draft_content_user_repo_file ON draft_content(user_id, repo_full_name, file_path); 183 - ``` 184 - 185 - ## API Specification 186 - 187 - ### Authentication Endpoints 188 - 189 - #### `POST /api/auth/github/login` 190 - Start GitHub OAuth flow. 191 - 192 - **Response:** 193 - ```json 194 - { 195 - "auth_url": "https://github.com/login/oauth/authorize?client_id=..." 196 - } 197 - ``` 198 - 199 - #### `GET /api/auth/github/callback` 200 - OAuth callback handler (redirects to frontend). 201 - 202 - **Query Params:** 203 - - `code`: OAuth authorization code 204 - - `state`: CSRF token 205 - 206 - **Redirects to:** `http://localhost:3000/dashboard?token={session_token}` 207 - 208 - #### `GET /api/auth/user` 209 - Get current authenticated user. 210 - 211 - **Headers:** 212 - - `Cookie: session=...` 213 - 214 - **Response:** 215 - ```json 216 - { 217 - "id": 1, 218 - "username": "johndoe", 219 - "avatar_url": "https://avatars.githubusercontent.com/...", 220 - "provider": "github" 221 - } 222 - ``` 223 - 224 - #### `POST /api/auth/logout` 225 - Logout and clear session. 226 - 227 - **Response:** 228 - ```json 229 - { 230 - "success": true 231 - } 232 - ``` 233 - 234 - ### Repository Endpoints 235 - 236 - #### `GET /api/repos` 237 - List user's repositories. 238 - 239 - **Query Params:** 240 - - `type`: `owner` | `collaborator` | `all` (default: `owner`) 241 - - `sort`: `updated` | `created` | `name` (default: `updated`) 242 - 243 - **Response:** 244 - ```json 245 - { 246 - "repositories": [ 247 - { 248 - "id": 12345, 249 - "full_name": "johndoe/blog", 250 - "name": "blog", 251 - "owner": "johndoe", 252 - "private": false, 253 - "default_branch": "main", 254 - "updated_at": "2026-01-19T10:30:00Z" 255 - } 256 - ] 257 - } 258 - ``` 259 - 260 - #### `GET /api/repos/:owner/:repo/files` 261 - List files in repository (recursive). 262 - 263 - **Query Params:** 264 - - `path`: Directory path (default: root) 265 - - `branch`: Branch name (default: default_branch) 266 - - `extensions`: Comma-separated list (e.g., `md,mdx`) 267 - 268 - **Response:** 269 - ```json 270 - { 271 - "files": [ 272 - { 273 - "path": "posts/hello-world.md", 274 - "name": "hello-world.md", 275 - "type": "file", 276 - "size": 1234, 277 - "sha": "abc123..." 278 - }, 279 - { 280 - "path": "posts/drafts", 281 - "name": "drafts", 282 - "type": "dir", 283 - "children": [...] 284 - } 285 - ], 286 - "current_branch": "main" 287 - } 288 - ``` 289 - 290 - #### `GET /api/repos/:owner/:repo/files/*path` 291 - Get file content. 292 - 293 - **Query Params:** 294 - - `branch`: Branch name (optional, defaults to default_branch) 295 - 296 - **Response:** 297 - ```json 298 - { 299 - "content": "# Hello World\n\nThis is my post...", 300 - "frontmatter": { 301 - "title": "Hello World", 302 - "date": "2026-01-19", 303 - "tags": ["intro", "blog"] 304 - }, 305 - "path": "posts/hello-world.md", 306 - "sha": "abc123...", 307 - "branch": "main" 308 - } 309 - ``` 310 - 311 - #### `PUT /api/repos/:owner/:repo/files/*path` 312 - Update file content (doesn't commit immediately, saves to draft). 313 - 314 - **Request Body:** 315 - ```json 316 - { 317 - "content": "# Updated content...", 318 - "frontmatter": { 319 - "title": "Updated Title", 320 - "date": "2026-01-19" 321 - } 322 - } 323 - ``` 324 - 325 - **Response:** 326 - ```json 327 - { 328 - "success": true, 329 - "draft_saved": true 330 - } 331 - ``` 332 - 333 - ### Branch & Publishing Endpoints 334 - 335 - #### `GET /api/repos/:owner/:repo/branch/status` 336 - Get current branch state for this repo. 337 - 338 - **Response:** 339 - ```json 340 - { 341 - "branch_name": "markedit-1705659600", 342 - "base_branch": "main", 343 - "has_changes": true, 344 - "last_push_at": "2026-01-19T09:30:00Z", 345 - "edited_files": ["posts/hello-world.md"], 346 - "hours_since_push": 1.5 347 - } 348 - ``` 349 - 350 - #### `POST /api/repos/:owner/:repo/publish` 351 - Commit changes and create PR. 352 - 353 - **Request Body:** 354 - ```json 355 - { 356 - "commit_message": "Update blog posts", 357 - "pr_title": "Blog updates from MarkEdit", 358 - "pr_description": "Updated the following files:\n- posts/hello-world.md", 359 - "files": ["posts/hello-world.md"] // optional, defaults to all changed files 360 - } 361 - ``` 362 - 363 - **Response:** 364 - ```json 365 - { 366 - "success": true, 367 - "branch": "markedit-1705659600", 368 - "commit_sha": "def456...", 369 - "pull_request": { 370 - "number": 42, 371 - "url": "https://github.com/johndoe/blog/pull/42", 372 - "html_url": "https://github.com/johndoe/blog/pull/42" 373 - } 374 - } 375 - ``` 376 - 377 - ### Health & Info 378 - 379 - #### `GET /api/health` 380 - Health check endpoint. 381 - 382 - **Response:** 383 - ```json 384 - { 385 - "status": "ok", 386 - "version": "0.1.0" 387 - } 388 - ``` 389 - 390 - ## Frontend Structure 391 - 392 - ### File Organization 393 - 394 - ``` 395 - frontend/ 396 - ├── src/ 397 - │ ├── components/ 398 - │ │ ├── editor/ 399 - │ │ │ ├── TipTapEditor.tsx # Main editor component 400 - │ │ │ ├── MenuBar.tsx # Editor toolbar 401 - │ │ │ ├── FrontmatterEditor.tsx # Frontmatter form 402 - │ │ │ └── extensions/ # Custom TipTap extensions 403 - │ │ ├── files/ 404 - │ │ │ ├── FileTree.tsx # Recursive file tree 405 - │ │ │ ├── FileNode.tsx # Single file/folder node 406 - │ │ │ └── RepoSelector.tsx # Dropdown for repo selection 407 - │ │ ├── layout/ 408 - │ │ │ ├── Header.tsx 409 - │ │ │ ├── Sidebar.tsx 410 - │ │ │ └── Layout.tsx 411 - │ │ └── ui/ # shadcn/ui components 412 - │ │ ├── button.tsx 413 - │ │ ├── dialog.tsx 414 - │ │ ├── dropdown-menu.tsx 415 - │ │ ├── input.tsx 416 - │ │ ├── label.tsx 417 - │ │ ├── separator.tsx 418 - │ │ ├── spinner.tsx 419 - │ │ └── ... (other shadcn/ui components) 420 - │ ├── lib/ 421 - │ │ ├── api/ 422 - │ │ │ ├── client.ts # Axios instance with interceptors 423 - │ │ │ ├── repos.ts # Repository API calls 424 - │ │ │ ├── files.ts # File API calls 425 - │ │ │ └── auth.ts # Auth API calls 426 - │ │ ├── utils/ 427 - │ │ │ ├── cn.ts # Tailwind class merging utility (shadcn) 428 - │ │ │ ├── markdown.ts # Markdown/frontmatter parsing 429 - │ │ │ ├── debounce.ts 430 - │ │ │ └── formatters.ts 431 - │ │ └── types/ 432 - │ │ ├── api.ts # API response types 433 - │ │ └── editor.ts # Editor-specific types 434 - │ ├── pages/ 435 - │ │ ├── index.astro # Landing/login page 436 - │ │ ├── dashboard.astro # Main app (mounts React) 437 - │ │ └── callback.astro # OAuth callback handler 438 - │ ├── stores/ 439 - │ │ ├── authStore.ts # Zustand or Context for auth 440 - │ │ └── editorStore.ts # Editor state 441 - │ └── styles/ 442 - │ └── global.css # Tailwind imports 443 - ├── public/ 444 - │ └── favicon.svg 445 - ├── astro.config.mjs 446 - ├── tailwind.config.mjs 447 - ├── components.json # shadcn/ui configuration 448 - ├── tsconfig.json 449 - └── package.json 450 - ``` 451 - 452 - ### Key Components 453 - 454 - #### TipTapEditor.tsx 455 - ```typescript 456 - interface TipTapEditorProps { 457 - initialContent: string; 458 - initialFrontmatter: Record<string, any>; 459 - onSave: (content: string, frontmatter: Record<string, any>) => void; 460 - autoSave?: boolean; 461 - autoSaveInterval?: number; // milliseconds 462 - } 463 - ``` 464 - 465 - #### FileTree.tsx 466 - ```typescript 467 - interface FileTreeProps { 468 - files: FileNode[]; 469 - selectedFile: string | null; 470 - onFileSelect: (path: string) => void; 471 - loading?: boolean; 472 - } 473 - ``` 474 - 475 - ### Pages 476 - 477 - #### `dashboard.astro` 478 - Main application shell. Mounts React app client-side. 479 - 480 - ```astro 481 - --- 482 - // No SSR, all client-side 483 - --- 484 - <html> 485 - <head> 486 - <title>MarkEdit</title> 487 - </head> 488 - <body> 489 - <div id="app"></div> 490 - <script> 491 - import App from '../components/App.tsx'; 492 - import { createRoot } from 'react-dom/client'; 493 - 494 - const root = createRoot(document.getElementById('app')); 495 - root.render(<App />); 496 - </script> 497 - </body> 498 - </html> 499 - ``` 500 - 501 - ### State Management 502 - 503 - Use React Query for server state: 504 - 505 - ```typescript 506 - // hooks/useRepositories.ts 507 - export function useRepositories() { 508 - return useQuery({ 509 - queryKey: ['repositories'], 510 - queryFn: () => api.repos.list() 511 - }); 512 - } 513 - 514 - // hooks/useFileContent.ts 515 - export function useFileContent(owner: string, repo: string, path: string) { 516 - return useQuery({ 517 - queryKey: ['file', owner, repo, path], 518 - queryFn: () => api.files.get(owner, repo, path), 519 - enabled: !!path 520 - }); 521 - } 522 - 523 - // hooks/useUpdateFile.ts 524 - export function useUpdateFile() { 525 - const queryClient = useQueryClient(); 526 - 527 - return useMutation({ 528 - mutationFn: (params) => api.files.update(params), 529 - onSuccess: () => { 530 - queryClient.invalidateQueries(['file']); 531 - } 532 - }); 533 - } 534 - ``` 535 - 536 - ## Branch Management Logic 537 - 538 - ### Branch Lifecycle 539 - 540 - ``` 541 - ┌─────────────────┐ 542 - │ Start Session │ 543 - └────────┬────────┘ 544 - 545 - 546 - Check DB for 547 - existing branch 548 - 549 - ┌────┴────┐ 550 - │ │ 551 - Found Not Found 552 - │ │ 553 - ▼ ▼ 554 - Check gap Create new 555 - > 4 hours? branch 556 - 557 - ┌───┴───┐ 558 - │ │ 559 - Yes No 560 - │ │ 561 - ▼ ▼ 562 - Delete Reuse 563 - Create branch 564 - new 565 - │ │ 566 - └───┬───┘ 567 - 568 - 569 - ┌─────────────────┐ 570 - │ User edits │ 571 - │ files │ 572 - └────────┬────────┘ 573 - 574 - 575 - Auto-save to 576 - draft table 577 - 578 - 579 - User clicks 580 - "Publish" 581 - 582 - 583 - ┌─────────────────┐ 584 - │ Commit & Push │ 585 - │ Create PR │ 586 - │ Update DB │ 587 - └────────┬────────┘ 588 - 589 - 590 - ┌─────────────────┐ 591 - │ Branch stays │ 592 - │ active for │ 593 - │ 4 hours │ 594 - └─────────────────┘ 595 - ``` 596 - 597 - ### Implementation (Go) 598 - 599 - ```go 600 - // internal/git/branch_manager.go 601 - 602 - type BranchManager struct { 603 - db *sql.DB 604 - git GitOperations 605 - } 606 - 607 - func (bm *BranchManager) GetOrCreateBranch(ctx context.Context, userID int, repoFullName string) (string, error) { 608 - // 1. Check database for existing branch 609 - state, err := bm.getBranchState(ctx, userID, repoFullName) 610 - if err != nil && err != sql.ErrNoRows { 611 - return "", err 612 - } 613 - 614 - // 2. No existing branch 615 - if state == nil { 616 - return bm.createNewBranch(ctx, userID, repoFullName) 617 - } 618 - 619 - // 3. Check time gap 620 - hoursSincePush := time.Since(state.LastPushAt).Hours() 621 - 622 - // 4. Gap > 4 hours and no uncommitted changes 623 - if hoursSincePush > 4 && !state.HasUncommittedChanges { 624 - // Delete old branch locally (not on remote) 625 - _ = bm.git.DeleteLocalBranch(repoFullName, state.BranchName) 626 - return bm.createNewBranch(ctx, userID, repoFullName) 627 - } 628 - 629 - // 5. Reuse existing branch 630 - return state.BranchName, nil 631 - } 632 - 633 - func (bm *BranchManager) createNewBranch(ctx context.Context, userID int, repoFullName string) (string, error) { 634 - branchName := fmt.Sprintf("markedit-%d", time.Now().Unix()) 635 - 636 - // Create branch in git 637 - if err := bm.git.CreateBranch(repoFullName, branchName); err != nil { 638 - return "", err 639 - } 640 - 641 - // Save to database 642 - if err := bm.saveBranchState(ctx, userID, repoFullName, branchName); err != nil { 643 - return "", err 644 - } 645 - 646 - return branchName, nil 647 - } 648 - 649 - func (bm *BranchManager) HandleConflict(ctx context.Context, userID int, repoFullName string) (string, error) { 650 - // Get current branch 651 - state, _ := bm.getBranchState(ctx, userID, repoFullName) 652 - 653 - // Delete old branch locally 654 - if state != nil { 655 - _ = bm.git.DeleteLocalBranch(repoFullName, state.BranchName) 656 - } 657 - 658 - // Create new branch 659 - return bm.createNewBranch(ctx, userID, repoFullName) 660 - } 661 - ``` 662 - 663 - ## Implementation Phases 664 - 665 - ### Phase 1: Foundation (Week 1-2) ✅ 666 - **Goal:** Basic authentication and file browsing 667 - 668 - **Backend Tasks:** 669 - - [x] Project setup (Go modules, directory structure) 670 - - [x] SQLite database setup with migrations 671 - - [x] GitHub OAuth integration with goth 672 - - [x] Session management with encrypted cookies 673 - - [x] Basic HTTP server with chi 674 - - [x] Health check endpoint 675 - - [x] GET /api/auth/github/login 676 - - [x] GET /api/auth/github/callback 677 - - [x] GET /api/auth/user 678 - - [x] GET /api/repos (list repositories) 679 - - [x] GET /api/repos/:owner/:repo/files (list files) 680 - 681 - **Frontend Tasks:** 682 - - [x] Astro project setup 683 - - [x] Tailwind CSS configuration 684 - - [x] shadcn/ui setup and initial components (button, dialog, input, etc.) 685 - - [x] Landing page with "Login with GitHub" button 686 - - [x] OAuth callback handler 687 - - [x] Dashboard shell (empty) 688 - - [x] Repository selector dropdown 689 - - [x] File tree component (read-only) 690 - - [x] API client setup (axios with interceptors) 691 - 692 - **Testing:** 693 - - [x] Manual: Complete OAuth flow 694 - - [x] Manual: View list of repositories 695 - - [x] Manual: Browse files in a test repository 696 - 697 - **Deliverable:** User can log in and browse their repositories and files. 698 - 699 - --- 700 - 701 - ### Phase 2: Editor Integration (Week 3-4) ✅ 702 - **Goal:** Read and edit markdown files 703 - 704 - **Backend Tasks:** 705 - - [x] GET /api/repos/:owner/:repo/files/*path (get file content) 706 - - [x] Markdown + YAML frontmatter parser 707 - - [x] PUT /api/repos/:owner/:repo/files/*path (save to draft) 708 - - [x] Draft content storage in SQLite 709 - 710 - **Frontend Tasks:** 711 - - [x] TipTap editor setup 712 - - [x] Markdown extensions for TipTap 713 - - [x] Frontmatter editor component (form) 714 - - [x] File selection handler 715 - - [x] Content display/edit toggle 716 - - [x] Auto-save functionality (debounced) 717 - - [x] Unsaved changes warning 718 - 719 - **Testing:** 720 - - [x] Manual: Open a markdown file 721 - - [x] Manual: Edit content and see auto-save 722 - - [x] Manual: Switch files and verify draft persistence 723 - 724 - **Deliverable:** User can read and edit markdown files with auto-save. 725 - 726 - --- 727 - 728 - ### Phase 3: Git Operations (Week 5-6) ✅ 729 - **Goal:** Branch management and publishing 730 - 731 - **Backend Tasks:** 732 - - [x] go-git integration 733 - - [x] Repository cloning/caching logic 734 - - [x] Branch manager implementation 735 - - [x] GET /api/repos/:owner/:repo/branch/status 736 - - [x] POST /api/repos/:owner/:repo/publish 737 - - [x] Commit creation 738 - - [x] Push to remote 739 - - [x] Pull request creation via GitHub API 740 - - [x] Branch state tracking in SQLite 741 - 742 - **Frontend Tasks:** 743 - - [x] Branch status display in UI 744 - - [x] "Publish" button with confirmation modal 745 - - [x] Commit message input 746 - - [x] PR title/description inputs 747 - - [x] Success notification with PR link 748 - - [x] Error handling for conflicts 749 - 750 - **Testing:** 751 - - [x] Manual: Edit file, publish, verify PR created 752 - - [ ] Manual: Edit multiple files, verify single PR 753 - - [ ] Manual: Test branch reuse within 4-hour window 754 - - [ ] Manual: Test new branch creation after 4 hours 755 - - [ ] Manual: Simulate conflict, verify new branch 756 - 757 - **Deliverable:** User can publish changes via Pull Requests. 758 - 759 - --- 760 - 761 - ### Phase 4: Polish & Deployment (Week 7-8) 762 - **Goal:** Production-ready Docker deployment 763 - 764 - **Tasks:** 765 - - [ ] Multi-stage Dockerfile 766 - - [ ] Docker Compose setup 767 - - [ ] Environment variable configuration 768 - - [ ] Build scripts 769 - - [ ] Error handling improvements 770 - - [ ] Loading states 771 - - [ ] Empty states (no repos, no files) 772 - - [ ] Responsive design testing 773 - - [ ] Security audit (token encryption, CORS, CSRF) 774 - - [ ] Basic documentation (README, SETUP.md) 775 - - [ ] GitHub Actions for builds (optional) 776 - 777 - **Testing:** 778 - - [ ] Manual: Full end-to-end workflow 779 - - [ ] Manual: Docker build and run 780 - - [ ] Manual: Test on different screen sizes 781 - - [ ] Security: Review auth flow, token storage 782 - 783 - **Deliverable:** Docker image ready for self-hosting. 784 - 785 - --- 786 - 787 - ### Phase 5: MVP Refinements (Week 9-10) 788 - **Goal:** UX improvements and bug fixes 789 - 790 - **Tasks:** 791 - - [ ] User feedback collection 792 - - [ ] Bug fixes from testing 793 - - [ ] Performance optimization (large repos) 794 - - [ ] Better error messages 795 - - [ ] Loading indicators 796 - - [ ] Accessibility improvements (keyboard nav, ARIA) 797 - - [ ] Dark mode support (optional) 798 - - [ ] GitHub Actions for CI/CD 799 - - [ ] Deployment guide for common platforms 800 - 801 - **Deliverable:** Stable MVP ready for open source release. 802 - 803 - ## Configuration 804 - 805 - ### Environment Variables 806 - 807 - ```bash 808 - # Backend (Go) 809 - # Server 810 - PORT=8080 811 - FRONTEND_URL=http://localhost:3000 812 - ALLOWED_ORIGINS=http://localhost:3000 813 - 814 - # GitHub OAuth 815 - GITHUB_CLIENT_ID=your_github_client_id 816 - GITHUB_CLIENT_SECRET=your_github_client_secret 817 - GITHUB_REDIRECT_URL=http://localhost:8080/api/auth/github/callback 818 - 819 - # Session 820 - SESSION_SECRET=your-random-session-secret-min-32-chars 821 - SESSION_SECURE=false # true in production (HTTPS only) 822 - SESSION_MAX_AGE=86400 # 24 hours 823 - 824 - # Database 825 - DATABASE_PATH=./data/markedit.db 826 - 827 - # Git 828 - GIT_CACHE_DIR=./data/repos 829 - GIT_AUTHOR_NAME=MarkEdit 830 - GIT_AUTHOR_EMAIL=markedit@example.com 831 - 832 - # Logging 833 - LOG_LEVEL=info # debug, info, warn, error 834 - ``` 835 - 836 - ### Development vs Production 837 - 838 - **Development:** 839 - ```bash 840 - # .env.development 841 - FRONTEND_URL=http://localhost:3000 842 - SESSION_SECURE=false 843 - LOG_LEVEL=debug 844 - ``` 845 - 846 - **Production:** 847 - ```bash 848 - # .env.production 849 - FRONTEND_URL=https://markedit.example.com 850 - SESSION_SECURE=true 851 - LOG_LEVEL=info 852 - ALLOWED_ORIGINS=https://markedit.example.com 853 - ``` 854 - 855 - ## File Structure (Complete) 856 - 857 - ``` 858 - markedit/ 859 - ├── backend/ 860 - │ ├── cmd/ 861 - │ │ └── server/ 862 - │ │ └── main.go # Entry point 863 - │ ├── internal/ 864 - │ │ ├── api/ 865 - │ │ │ ├── handlers/ 866 - │ │ │ │ ├── auth.go # Auth handlers 867 - │ │ │ │ ├── repos.go # Repository handlers 868 - │ │ │ │ ├── files.go # File handlers 869 - │ │ │ │ └── branch.go # Branch/publish handlers 870 - │ │ │ ├── middleware/ 871 - │ │ │ │ ├── auth.go # Auth middleware 872 - │ │ │ │ ├── cors.go # CORS middleware 873 - │ │ │ │ └── logger.go # Logging middleware 874 - │ │ │ └── router.go # Route setup 875 - │ │ ├── auth/ 876 - │ │ │ ├── github.go # GitHub OAuth setup 877 - │ │ │ └── session.go # Session management 878 - │ │ ├── connectors/ 879 - │ │ │ ├── connector.go # Interface definition 880 - │ │ │ ├── github.go # GitHub implementation 881 - │ │ │ └── factory.go # Connector factory 882 - │ │ ├── git/ 883 - │ │ │ ├── operations.go # Git operations (clone, commit, push) 884 - │ │ │ ├── branch_manager.go # Branch lifecycle logic 885 - │ │ │ └── cache.go # Repository caching 886 - │ │ ├── database/ 887 - │ │ │ ├── db.go # Database connection 888 - │ │ │ ├── migrations/ 889 - │ │ │ │ └── 001_initial.sql 890 - │ │ │ ├── queries/ 891 - │ │ │ │ ├── users.sql 892 - │ │ │ │ ├── auth_tokens.sql 893 - │ │ │ │ ├── branch_states.sql 894 - │ │ │ │ └── draft_content.sql 895 - │ │ │ └── models.go # Data models 896 - │ │ ├── markdown/ 897 - │ │ │ ├── parser.go # Frontmatter parser 898 - │ │ │ └── serializer.go # Frontmatter serializer 899 - │ │ └── config/ 900 - │ │ └── config.go # Configuration loading 901 - │ ├── pkg/ 902 - │ │ └── types/ 903 - │ │ ├── api.go # API types 904 - │ │ └── errors.go # Custom errors 905 - │ ├── migrations/ 906 - │ │ └── 001_initial.sql 907 - │ ├── go.mod 908 - │ ├── go.sum 909 - │ └── Makefile 910 - ├── frontend/ 911 - │ ├── src/ 912 - │ │ ├── components/ 913 - │ │ │ ├── editor/ 914 - │ │ │ │ ├── TipTapEditor.tsx 915 - │ │ │ │ ├── MenuBar.tsx 916 - │ │ │ │ ├── FrontmatterEditor.tsx 917 - │ │ │ │ └── extensions/ 918 - │ │ │ │ ├── markdown.ts 919 - │ │ │ │ └── codeBlock.ts 920 - │ │ │ ├── files/ 921 - │ │ │ │ ├── FileTree.tsx 922 - │ │ │ │ ├── FileNode.tsx 923 - │ │ │ │ └── RepoSelector.tsx 924 - │ │ │ ├── layout/ 925 - │ │ │ │ ├── Header.tsx 926 - │ │ │ │ ├── Sidebar.tsx 927 - │ │ │ │ └── Layout.tsx 928 - │ │ │ ├── ui/ # shadcn/ui components 929 - │ │ │ │ ├── button.tsx 930 - │ │ │ │ ├── dialog.tsx 931 - │ │ │ │ ├── dropdown-menu.tsx 932 - │ │ │ │ ├── input.tsx 933 - │ │ │ │ ├── label.tsx 934 - │ │ │ │ ├── separator.tsx 935 - │ │ │ │ └── ... (other shadcn/ui components) 936 - │ │ │ └── App.tsx # Main React app 937 - │ │ ├── lib/ 938 - │ │ │ ├── api/ 939 - │ │ │ │ ├── client.ts 940 - │ │ │ │ ├── auth.ts 941 - │ │ │ │ ├── repos.ts 942 - │ │ │ │ └── files.ts 943 - │ │ │ ├── hooks/ 944 - │ │ │ │ ├── useAuth.ts 945 - │ │ │ │ ├── useRepositories.ts 946 - │ │ │ │ ├── useFiles.ts 947 - │ │ │ │ └── useFileContent.ts 948 - │ │ │ ├── utils/ 949 - │ │ │ │ ├── cn.ts # Tailwind class merging utility 950 - │ │ │ │ ├── markdown.ts 951 - │ │ │ │ ├── debounce.ts 952 - │ │ │ │ └── formatters.ts 953 - │ │ │ └── types/ 954 - │ │ │ ├── api.ts 955 - │ │ │ └── editor.ts 956 - │ │ ├── stores/ 957 - │ │ │ └── authStore.ts 958 - │ │ ├── pages/ 959 - │ │ │ ├── index.astro 960 - │ │ │ ├── dashboard.astro 961 - │ │ │ └── callback.astro 962 - │ │ └── styles/ 963 - │ │ └── global.css 964 - │ ├── public/ 965 - │ │ └── favicon.svg 966 - │ ├── astro.config.mjs 967 - │ ├── tailwind.config.mjs 968 - │ ├── components.json # shadcn/ui configuration 969 - │ ├── tsconfig.json 970 - │ └── package.json 971 - ├── docker/ 972 - │ ├── Dockerfile # Multi-stage build 973 - │ └── docker-compose.yml 974 - ├── .github/ 975 - │ └── workflows/ 976 - │ ├── build.yml # CI build 977 - │ └── release.yml # Release automation 978 - ├── docs/ 979 - │ ├── SETUP.md # Setup instructions 980 - │ ├── DEPLOYMENT.md # Deployment guide 981 - │ └── ARCHITECTURE.md # Architecture details 982 - ├── .env.example 983 - ├── .gitignore 984 - ├── README.md 985 - ├── LICENSE 986 - └── IMPLEMENTATION_PLAN.md # This file 987 - ``` 988 - 989 - ## Development Workflow 990 - 991 - ### Initial Setup 992 - 993 - ```bash 994 - # Clone repository 995 - git clone https://github.com/yourusername/markedit.git 996 - cd markedit 997 - 998 - # Backend setup 999 - cd backend 1000 - cp ../.env.example .env 1001 - # Edit .env with your GitHub OAuth credentials 1002 - go mod download 1003 - make migrate-up 1004 - make run 1005 - 1006 - # Frontend setup (new terminal) 1007 - cd frontend 1008 - bun install 1009 - # Initialize shadcn/ui (if not already done) 1010 - bunx shadcn-ui@latest init 1011 - bun run dev 1012 - 1013 - # Visit http://localhost:3000 1014 - ``` 1015 - 1016 - ### Docker Development 1017 - 1018 - ```bash 1019 - # Build and run 1020 - docker-compose up --build 1021 - 1022 - # Visit http://localhost:3000 1023 - ``` 1024 - 1025 - ### GitHub OAuth Setup 1026 - 1027 - 1. Go to https://github.com/settings/developers 1028 - 2. Create new OAuth App 1029 - 3. Set Authorization callback URL: `http://localhost:8080/api/auth/github/callback` 1030 - 4. Copy Client ID and Client Secret to `.env` 1031 - 1032 - ## Testing Strategy 1033 - 1034 - ### Manual Testing Checklist (MVP) 1035 - 1036 - **Authentication:** 1037 - - [ ] Login with GitHub redirects correctly 1038 - - [ ] Callback handles OAuth code 1039 - - [ ] Session persists across page refreshes 1040 - - [ ] Logout clears session 1041 - - [ ] Unauthorized requests redirect to login 1042 - 1043 - **Repository Browsing:** 1044 - - [ ] List displays all user repositories 1045 - - [ ] Repository selection loads file tree 1046 - - [ ] File tree shows directories and markdown files 1047 - - [ ] File tree filters non-markdown files 1048 - 1049 - **File Editing:** 1050 - - [ ] Selecting file loads content in editor 1051 - - [ ] Frontmatter displays in separate form 1052 - - [ ] Editor shows markdown content 1053 - - [ ] Auto-save triggers after edits 1054 - - [ ] Switching files saves current file 1055 - - [ ] Browser refresh preserves unsaved changes 1056 - 1057 - **Publishing:** 1058 - - [ ] Branch status shows correct information 1059 - - [ ] Publish button enables when changes exist 1060 - - [ ] Commit message is required 1061 - - [ ] PR creation succeeds 1062 - - [ ] PR link is displayed 1063 - - [ ] Branch reuse works within 4 hours 1064 - - [ ] New branch created after 4-hour gap 1065 - - [ ] Conflict creates new branch 1066 - 1067 - **Error Handling:** 1068 - - [ ] Network errors show user-friendly messages 1069 - - [ ] API errors are logged and displayed 1070 - - [ ] 401 errors redirect to login 1071 - - [ ] Invalid file paths show error 1072 - 1073 - ### Future Testing (Post-MVP) 1074 - 1075 - - Unit tests for Go backend (testify) 1076 - - Integration tests for API endpoints 1077 - - E2E tests with Playwright 1078 - - Performance testing with large repositories 1079 - 1080 - ## Security Considerations 1081 - 1082 - ### Current Implementation 1083 - 1084 - 1. **OAuth Token Storage:** 1085 - - Encrypted in SQLite using AES-256 1086 - - Never exposed to frontend 1087 - - Backend proxies all GitHub API calls 1088 - 1089 - 2. **Session Management:** 1090 - - HTTP-only cookies 1091 - - Secure flag in production (HTTPS) 1092 - - CSRF protection via SameSite=Lax 1093 - - Encrypted session data 1094 - 1095 - 3. **CORS:** 1096 - - Whitelist specific origins 1097 - - Credentials included in requests 1098 - 1099 - 4. **Input Validation:** 1100 - - Sanitize file paths 1101 - - Validate repository ownership 1102 - - Limit file sizes 1103 - 1104 - ### Future Enhancements 1105 - 1106 - - Rate limiting 1107 - - Content Security Policy (CSP) 1108 - - Subresource Integrity (SRI) 1109 - - Audit logging 1110 - - RBAC when multi-user support is added 1111 - 1112 - ## Performance Considerations 1113 - 1114 - ### Backend 1115 - 1116 - - **Repository Caching:** Clone repos to local disk, pull for updates 1117 - - **Database Indexes:** On user_id, repo_full_name, file_path 1118 - - **Connection Pooling:** SQLite with WAL mode 1119 - - **Compression:** gzip middleware for API responses 1120 - 1121 - ### Frontend 1122 - 1123 - - **Code Splitting:** Lazy load TipTap editor 1124 - - **Virtualization:** Virtual scrolling for large file trees (react-window) 1125 - - **Debouncing:** Auto-save with 2-second debounce 1126 - - **Memoization:** React.memo for FileNode components 1127 - 1128 - ### Docker 1129 - 1130 - - **Multi-stage Build:** Separate build and runtime images 1131 - - **Layer Caching:** Optimize Dockerfile for faster rebuilds 1132 - - **Image Size:** Use Alpine or scratch base 1133 - 1134 - ## Open Questions & Future Decisions 1135 - 1136 - 1. **Repository Size Limits:** What's the max repo size we'll support? 1137 - 2. **Concurrent Editing:** How to handle multiple browser tabs? 1138 - 3. **Image Embedding:** When we add image uploads, where to store (Git LFS, S3)? 1139 - 4. **MDX Support:** Custom TipTap extensions or separate editor mode? 1140 - 5. **Mobile Support:** Native app or PWA? 1141 - 6. **Offline Mode:** Service worker for offline editing? 1142 - 7. **Collaboration:** Real-time editing with WebSockets/CRDTs? 1143 - 1144 - ## Success Metrics (Post-Launch) 1145 - 1146 - - **Technical:** 1147 - - Docker image < 50MB 1148 - - API response time < 200ms (p95) 1149 - - File tree render < 500ms for 1000 files 1150 - - Auto-save latency < 100ms 1151 - 1152 - - **User Experience:** 1153 - - OAuth flow completion rate > 90% 1154 - - Time to first edit < 30 seconds 1155 - - PR creation success rate > 95% 1156 - 1157 - ## Next Steps 1158 - 1159 - 1. **Set up development environment** (Go, Node.js, Docker) 1160 - 2. **Create GitHub OAuth app** for testing 1161 - 3. **Start with Phase 1:** Backend authentication 1162 - 4. **Daily commits** to track progress 1163 - 5. **Weekly demos** to validate UX 1164 - 1165 - --- 1166 - 1167 - ## Additional Resources 1168 - 1169 - - **TipTap:** https://tiptap.dev/docs 1170 - - **shadcn/ui:** https://ui.shadcn.com 1171 - - **Astro with React:** https://docs.astro.build/en/guides/integrations-guide/react/ 1172 - - **go-git:** https://github.com/go-git/go-git 1173 - - **goth:** https://github.com/markbates/goth 1174 - - **Astro:** https://docs.astro.build 1175 - - **React Query:** https://tanstack.com/query/latest 1176 - 1177 - --- 1178 - 1179 - **Document Version:** 1.0 1180 - **Last Updated:** 2026-01-19 1181 - **Status:** Draft - Ready for Implementation
+87 -31
backend/internal/api/handlers/branch.go
··· 1 1 package handlers 2 2 3 3 import ( 4 + "context" 4 5 "encoding/json" 5 6 "fmt" 6 7 "log" ··· 177 178 repoFullName := fmt.Sprintf("%s/%s", owner, repo) 178 179 cloneURL := fmt.Sprintf("https://github.com/%s.git", repoFullName) 179 180 181 + // Create GitHub client early (needed for rename operations) 182 + ts := oauth2.StaticTokenSource(&oauth2.Token{AccessToken: token.AccessToken}) 183 + tc := oauth2.NewClient(ctx, ts) 184 + client := github.NewClient(tc) 185 + 180 186 // Clone or open repository 181 187 gitRepo, err := h.gitOps.CloneOrOpen(ctx, repoFullName, cloneURL, token.AccessToken) 182 188 if err != nil { ··· 193 199 return 194 200 } 195 201 196 - // Get draft files and write them to the repository 197 - filesToCommit := req.Files 198 - if len(filesToCommit) == 0 { 199 - // Get all draft files for this repository 200 - // For now, we'll require files to be specified 201 - http.Error(w, "No files specified to publish", http.StatusBadRequest) 202 + // Get all draft changes for this repository 203 + drafts, err := h.db.GetAllDraftContent(userID, repoFullName) 204 + if err != nil { 205 + log.Printf("Failed to get draft content: %v", err) 206 + http.Error(w, "Failed to get draft content", http.StatusInternalServerError) 202 207 return 203 208 } 204 209 205 - // Write draft content to repository files 206 - for _, filePath := range filesToCommit { 207 - draft, err := h.db.GetDraftContent(userID, repoFullName, filePath) 208 - if err != nil { 209 - log.Printf("Failed to get draft for %s: %v", filePath, err) 210 - continue 211 - } 212 - if draft == nil { 213 - log.Printf("No draft found for %s", filePath) 214 - continue 215 - } 210 + if len(drafts) == 0 { 211 + http.Error(w, "No pending changes to publish", http.StatusBadRequest) 212 + return 213 + } 216 214 217 - if err := h.gitOps.WriteFile(repoFullName, filePath, draft.Content); err != nil { 218 - log.Printf("Failed to write file %s: %v", filePath, err) 219 - http.Error(w, fmt.Sprintf("Failed to write file: %v", err), http.StatusInternalServerError) 220 - return 215 + // Track all files that need to be committed 216 + var filesToCommit []string 217 + 218 + // Process each draft based on change type 219 + for _, draft := range drafts { 220 + switch draft.ChangeType { 221 + case "edit", "new_file", "new_folder": 222 + // For edits and new files/folders, write the content 223 + if err := h.gitOps.WriteFile(repoFullName, draft.FilePath, draft.Content); err != nil { 224 + log.Printf("Failed to write file %s: %v", draft.FilePath, err) 225 + http.Error(w, fmt.Sprintf("Failed to write file: %v", err), http.StatusInternalServerError) 226 + return 227 + } 228 + filesToCommit = append(filesToCommit, draft.FilePath) 229 + 230 + case "rename": 231 + // For renames, we need to: 232 + // 1. Get content from the original file on GitHub 233 + // 2. Write it to the new path 234 + // 3. Stage the new file and the deletion 235 + content, err := h.getFileContentFromGitHub(ctx, client, owner, repo, draft.OriginalPath) 236 + if err != nil { 237 + log.Printf("Failed to get content for rename from %s: %v", draft.OriginalPath, err) 238 + http.Error(w, fmt.Sprintf("Failed to get file content for rename: %v", err), http.StatusInternalServerError) 239 + return 240 + } 241 + 242 + // Write to new location 243 + if err := h.gitOps.WriteFile(repoFullName, draft.FilePath, content); err != nil { 244 + log.Printf("Failed to write renamed file %s: %v", draft.FilePath, err) 245 + http.Error(w, fmt.Sprintf("Failed to write renamed file: %v", err), http.StatusInternalServerError) 246 + return 247 + } 248 + 249 + // Delete old file 250 + if err := h.gitOps.DeleteFile(repoFullName, draft.OriginalPath); err != nil { 251 + log.Printf("Failed to delete renamed file %s: %v", draft.OriginalPath, err) 252 + // Continue even if delete fails 253 + } 254 + 255 + filesToCommit = append(filesToCommit, draft.FilePath, draft.OriginalPath) 256 + 257 + default: 258 + // Treat unknown types as edits 259 + if err := h.gitOps.WriteFile(repoFullName, draft.FilePath, draft.Content); err != nil { 260 + log.Printf("Failed to write file %s: %v", draft.FilePath, err) 261 + http.Error(w, fmt.Sprintf("Failed to write file: %v", err), http.StatusInternalServerError) 262 + return 263 + } 264 + filesToCommit = append(filesToCommit, draft.FilePath) 221 265 } 222 266 } 223 267 ··· 240 284 241 285 log.Printf("Pushed branch %s", branchName) 242 286 243 - // Create GitHub client 244 - ts := oauth2.StaticTokenSource(&oauth2.Token{AccessToken: token.AccessToken}) 245 - tc := oauth2.NewClient(ctx, ts) 246 - client := github.NewClient(tc) 247 - 248 287 // Create pull request 249 288 prTitle := req.PRTitle 250 289 if prTitle == "" { ··· 285 324 log.Printf("Failed to update branch state: %v", err) 286 325 } 287 326 288 - // Delete draft content for published files 289 - for _, filePath := range filesToCommit { 290 - if err := h.db.DeleteDraftContent(userID, repoFullName, filePath); err != nil { 291 - log.Printf("Failed to delete draft for %s: %v", filePath, err) 292 - } 327 + // Delete all draft content for this repository after successful publish 328 + if err := h.db.DeleteAllDraftContent(userID, repoFullName); err != nil { 329 + log.Printf("Failed to delete draft content: %v", err) 293 330 } 294 331 295 332 response := PublishResponse{ ··· 302 339 w.Header().Set("Content-Type", "application/json") 303 340 json.NewEncoder(w).Encode(response) 304 341 } 342 + 343 + // getFileContentFromGitHub fetches the content of a file from GitHub 344 + func (h *BranchHandler) getFileContentFromGitHub(ctx context.Context, client *github.Client, owner, repo, path string) (string, error) { 345 + fileContent, _, _, err := client.Repositories.GetContents(ctx, owner, repo, path, nil) 346 + if err != nil { 347 + return "", fmt.Errorf("failed to get file content from GitHub: %w", err) 348 + } 349 + 350 + if fileContent == nil { 351 + return "", fmt.Errorf("file not found on GitHub") 352 + } 353 + 354 + content, err := fileContent.GetContent() 355 + if err != nil { 356 + return "", fmt.Errorf("failed to decode content: %w", err) 357 + } 358 + 359 + return content, nil 360 + }
+340
backend/internal/api/handlers/repos.go
··· 6 6 "log" 7 7 "net/http" 8 8 "strings" 9 + "time" 9 10 10 11 "github.com/go-chi/chi/v5" 11 12 "github.com/yourusername/markedit/internal/auth" ··· 313 314 "invalidated_count": count, 314 315 }) 315 316 } 317 + 318 + // CreateFileRequest represents the request to create a new file 319 + type CreateFileRequest struct { 320 + Path string `json:"path"` 321 + Content string `json:"content"` 322 + Message string `json:"message"` 323 + } 324 + 325 + // CreateFile creates a new file as a draft change (not immediately pushed) 326 + func (h *RepoHandler) CreateFile(w http.ResponseWriter, r *http.Request) { 327 + owner := chi.URLParam(r, "owner") 328 + repo := chi.URLParam(r, "repo") 329 + 330 + if owner == "" || repo == "" { 331 + http.Error(w, "Missing owner or repo parameter", http.StatusBadRequest) 332 + return 333 + } 334 + 335 + // Get user from session 336 + session, err := auth.GetSession(r) 337 + if err != nil { 338 + http.Error(w, "Unauthorized", http.StatusUnauthorized) 339 + return 340 + } 341 + 342 + userID, ok := auth.GetUserID(session) 343 + if !ok { 344 + http.Error(w, "Unauthorized", http.StatusUnauthorized) 345 + return 346 + } 347 + 348 + // Parse request body 349 + var req CreateFileRequest 350 + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { 351 + http.Error(w, "Invalid request body", http.StatusBadRequest) 352 + return 353 + } 354 + 355 + if req.Path == "" { 356 + http.Error(w, "Path is required", http.StatusBadRequest) 357 + return 358 + } 359 + 360 + // Save as draft instead of creating immediately 361 + repoFullName := fmt.Sprintf("%s/%s", owner, repo) 362 + draft := &database.DraftContent{ 363 + UserID: userID, 364 + RepoFullName: repoFullName, 365 + FilePath: req.Path, 366 + Content: req.Content, 367 + ChangeType: "new_file", 368 + } 369 + 370 + if err := h.db.SaveDraftContent(draft); err != nil { 371 + log.Printf("Failed to save draft for new file: %v", err) 372 + http.Error(w, "Failed to save draft", http.StatusInternalServerError) 373 + return 374 + } 375 + 376 + // Update branch state to indicate uncommitted changes 377 + state := &database.BranchState{ 378 + UserID: userID, 379 + RepoFullName: repoFullName, 380 + BranchName: "", 381 + BaseBranch: "main", 382 + LastPushAt: time.Now(), 383 + HasUncommittedChanges: true, 384 + FilePaths: fmt.Sprintf("[\"%s\"]", req.Path), 385 + } 386 + if err := h.db.SaveBranchState(state); err != nil { 387 + log.Printf("Warning: Failed to save branch state: %v", err) 388 + } 389 + 390 + w.Header().Set("Content-Type", "application/json") 391 + json.NewEncoder(w).Encode(map[string]interface{}{ 392 + "success": true, 393 + "path": req.Path, 394 + "draft_saved": true, 395 + }) 396 + } 397 + 398 + // CreateFolderRequest represents the request to create a new folder 399 + type CreateFolderRequest struct { 400 + Path string `json:"path"` 401 + Message string `json:"message"` 402 + } 403 + 404 + // CreateFolder creates a new folder as a draft change (not immediately pushed) 405 + func (h *RepoHandler) CreateFolder(w http.ResponseWriter, r *http.Request) { 406 + owner := chi.URLParam(r, "owner") 407 + repo := chi.URLParam(r, "repo") 408 + 409 + if owner == "" || repo == "" { 410 + http.Error(w, "Missing owner or repo parameter", http.StatusBadRequest) 411 + return 412 + } 413 + 414 + // Get user from session 415 + session, err := auth.GetSession(r) 416 + if err != nil { 417 + http.Error(w, "Unauthorized", http.StatusUnauthorized) 418 + return 419 + } 420 + 421 + userID, ok := auth.GetUserID(session) 422 + if !ok { 423 + http.Error(w, "Unauthorized", http.StatusUnauthorized) 424 + return 425 + } 426 + 427 + // Parse request body 428 + var req CreateFolderRequest 429 + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { 430 + http.Error(w, "Invalid request body", http.StatusBadRequest) 431 + return 432 + } 433 + 434 + if req.Path == "" { 435 + http.Error(w, "Path is required", http.StatusBadRequest) 436 + return 437 + } 438 + 439 + // Save as draft instead of creating immediately 440 + repoFullName := fmt.Sprintf("%s/%s", owner, repo) 441 + folderPath := strings.TrimSuffix(req.Path, "/") 442 + gitkeepPath := folderPath + "/.gitkeep" 443 + 444 + draft := &database.DraftContent{ 445 + UserID: userID, 446 + RepoFullName: repoFullName, 447 + FilePath: gitkeepPath, 448 + Content: "", 449 + ChangeType: "new_folder", 450 + } 451 + 452 + if err := h.db.SaveDraftContent(draft); err != nil { 453 + log.Printf("Failed to save draft for new folder: %v", err) 454 + http.Error(w, "Failed to save draft", http.StatusInternalServerError) 455 + return 456 + } 457 + 458 + // Update branch state to indicate uncommitted changes 459 + state := &database.BranchState{ 460 + UserID: userID, 461 + RepoFullName: repoFullName, 462 + BranchName: "", 463 + BaseBranch: "main", 464 + LastPushAt: time.Now(), 465 + HasUncommittedChanges: true, 466 + FilePaths: fmt.Sprintf("[\"%s\"]", gitkeepPath), 467 + } 468 + if err := h.db.SaveBranchState(state); err != nil { 469 + log.Printf("Warning: Failed to save branch state: %v", err) 470 + } 471 + 472 + w.Header().Set("Content-Type", "application/json") 473 + json.NewEncoder(w).Encode(map[string]interface{}{ 474 + "success": true, 475 + "path": folderPath, 476 + "draft_saved": true, 477 + }) 478 + } 479 + 480 + // RenameItemRequest represents the request to rename a file or folder 481 + type RenameItemRequest struct { 482 + OldPath string `json:"old_path"` 483 + NewPath string `json:"new_path"` 484 + Message string `json:"message"` 485 + } 486 + 487 + // RenameItem saves a rename operation as a draft change (not immediately pushed) 488 + func (h *RepoHandler) RenameItem(w http.ResponseWriter, r *http.Request) { 489 + owner := chi.URLParam(r, "owner") 490 + repo := chi.URLParam(r, "repo") 491 + 492 + if owner == "" || repo == "" { 493 + http.Error(w, "Missing owner or repo parameter", http.StatusBadRequest) 494 + return 495 + } 496 + 497 + // Get user from session 498 + session, err := auth.GetSession(r) 499 + if err != nil { 500 + http.Error(w, "Unauthorized", http.StatusUnauthorized) 501 + return 502 + } 503 + 504 + userID, ok := auth.GetUserID(session) 505 + if !ok { 506 + http.Error(w, "Unauthorized", http.StatusUnauthorized) 507 + return 508 + } 509 + 510 + // Parse request body 511 + var req RenameItemRequest 512 + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { 513 + http.Error(w, "Invalid request body", http.StatusBadRequest) 514 + return 515 + } 516 + 517 + if req.OldPath == "" || req.NewPath == "" { 518 + http.Error(w, "Both old_path and new_path are required", http.StatusBadRequest) 519 + return 520 + } 521 + 522 + // Save as draft instead of renaming immediately 523 + repoFullName := fmt.Sprintf("%s/%s", owner, repo) 524 + 525 + // For renames, we store the new path as file_path and old path as original_path 526 + draft := &database.DraftContent{ 527 + UserID: userID, 528 + RepoFullName: repoFullName, 529 + FilePath: req.NewPath, 530 + Content: "", // Content will be fetched from original file during publish 531 + ChangeType: "rename", 532 + OriginalPath: req.OldPath, 533 + } 534 + 535 + if err := h.db.SaveDraftContent(draft); err != nil { 536 + log.Printf("Failed to save draft for rename: %v", err) 537 + http.Error(w, "Failed to save draft", http.StatusInternalServerError) 538 + return 539 + } 540 + 541 + // Update branch state to indicate uncommitted changes 542 + state := &database.BranchState{ 543 + UserID: userID, 544 + RepoFullName: repoFullName, 545 + BranchName: "", 546 + BaseBranch: "main", 547 + LastPushAt: time.Now(), 548 + HasUncommittedChanges: true, 549 + FilePaths: fmt.Sprintf("[\"%s\"]", req.NewPath), 550 + } 551 + if err := h.db.SaveBranchState(state); err != nil { 552 + log.Printf("Warning: Failed to save branch state: %v", err) 553 + } 554 + 555 + w.Header().Set("Content-Type", "application/json") 556 + json.NewEncoder(w).Encode(map[string]interface{}{ 557 + "success": true, 558 + "old_path": req.OldPath, 559 + "new_path": req.NewPath, 560 + "draft_saved": true, 561 + }) 562 + } 563 + 564 + // GetPendingChanges returns all pending draft changes for a repository 565 + func (h *RepoHandler) GetPendingChanges(w http.ResponseWriter, r *http.Request) { 566 + owner := chi.URLParam(r, "owner") 567 + repo := chi.URLParam(r, "repo") 568 + 569 + if owner == "" || repo == "" { 570 + http.Error(w, "Missing owner or repo parameter", http.StatusBadRequest) 571 + return 572 + } 573 + 574 + // Get user from session 575 + session, err := auth.GetSession(r) 576 + if err != nil { 577 + http.Error(w, "Unauthorized", http.StatusUnauthorized) 578 + return 579 + } 580 + 581 + userID, ok := auth.GetUserID(session) 582 + if !ok { 583 + http.Error(w, "Unauthorized", http.StatusUnauthorized) 584 + return 585 + } 586 + 587 + repoFullName := fmt.Sprintf("%s/%s", owner, repo) 588 + 589 + // Get all draft content for this repository 590 + drafts, err := h.db.GetAllDraftContent(userID, repoFullName) 591 + if err != nil { 592 + log.Printf("Failed to get pending changes: %v", err) 593 + http.Error(w, "Failed to get pending changes", http.StatusInternalServerError) 594 + return 595 + } 596 + 597 + w.Header().Set("Content-Type", "application/json") 598 + json.NewEncoder(w).Encode(map[string]interface{}{ 599 + "pending_changes": drafts, 600 + "count": len(drafts), 601 + }) 602 + } 603 + 604 + // DiscardChanges removes all pending draft changes for a repository 605 + func (h *RepoHandler) DiscardChanges(w http.ResponseWriter, r *http.Request) { 606 + owner := chi.URLParam(r, "owner") 607 + repo := chi.URLParam(r, "repo") 608 + 609 + if owner == "" || repo == "" { 610 + http.Error(w, "Missing owner or repo parameter", http.StatusBadRequest) 611 + return 612 + } 613 + 614 + // Get user from session 615 + session, err := auth.GetSession(r) 616 + if err != nil { 617 + http.Error(w, "Unauthorized", http.StatusUnauthorized) 618 + return 619 + } 620 + 621 + userID, ok := auth.GetUserID(session) 622 + if !ok { 623 + http.Error(w, "Unauthorized", http.StatusUnauthorized) 624 + return 625 + } 626 + 627 + repoFullName := fmt.Sprintf("%s/%s", owner, repo) 628 + 629 + // Delete all draft content for this repository 630 + if err := h.db.DeleteAllDraftContent(userID, repoFullName); err != nil { 631 + log.Printf("Failed to discard changes: %v", err) 632 + http.Error(w, "Failed to discard changes", http.StatusInternalServerError) 633 + return 634 + } 635 + 636 + // Reset branch state to indicate no uncommitted changes 637 + state := &database.BranchState{ 638 + UserID: userID, 639 + RepoFullName: repoFullName, 640 + BranchName: "", 641 + BaseBranch: "main", 642 + LastPushAt: time.Now(), 643 + HasUncommittedChanges: false, 644 + FilePaths: "[]", 645 + } 646 + if err := h.db.SaveBranchState(state); err != nil { 647 + log.Printf("Warning: Failed to update branch state: %v", err) 648 + } 649 + 650 + w.Header().Set("Content-Type", "application/json") 651 + json.NewEncoder(w).Encode(map[string]interface{}{ 652 + "success": true, 653 + "message": "All pending changes have been discarded", 654 + }) 655 + }
+7
backend/internal/api/router.go
··· 70 70 r.Put("/api/repos/{owner}/{repo}/files/*", repoHandler.UpdateFileContent) 71 71 r.Delete("/api/repos/{owner}/{repo}/cache", repoHandler.InvalidateCache) 72 72 73 + // File management routes 74 + r.Post("/api/repos/{owner}/{repo}/create-file", repoHandler.CreateFile) 75 + r.Post("/api/repos/{owner}/{repo}/create-folder", repoHandler.CreateFolder) 76 + r.Post("/api/repos/{owner}/{repo}/rename", repoHandler.RenameItem) 77 + r.Get("/api/repos/{owner}/{repo}/pending-changes", repoHandler.GetPendingChanges) 78 + r.Delete("/api/repos/{owner}/{repo}/pending-changes", repoHandler.DiscardChanges) 79 + 73 80 // Branch routes 74 81 r.Get("/api/repos/{owner}/{repo}/branch/status", branchHandler.GetBranchStatus) 75 82 r.Post("/api/repos/{owner}/{repo}/publish", branchHandler.Publish)
+134
backend/internal/connectors/github.go
··· 2 2 3 3 import ( 4 4 "context" 5 + "encoding/base64" 5 6 "fmt" 6 7 "os" 7 8 "path/filepath" ··· 470 471 pattern := fmt.Sprintf("%s/%s/*", owner, repo) 471 472 return globalCache.InvalidatePattern(pattern) 472 473 } 474 + 475 + // CreateFile creates a new file in the repository 476 + func (g *GitHubConnector) CreateFile(ctx context.Context, owner, repo, path, content, message string) error { 477 + if message == "" { 478 + message = fmt.Sprintf("Create %s", path) 479 + } 480 + 481 + // Get default branch 482 + repository, _, err := g.client.Repositories.Get(ctx, owner, repo) 483 + if err != nil { 484 + return fmt.Errorf("failed to get repository: %w", err) 485 + } 486 + branch := repository.GetDefaultBranch() 487 + 488 + // Encode content to base64 489 + encodedContent := github.String(base64.StdEncoding.EncodeToString([]byte(content))) 490 + 491 + // Create file options 492 + opts := &github.RepositoryContentFileOptions{ 493 + Message: github.String(message), 494 + Content: []byte(*encodedContent), 495 + Branch: github.String(branch), 496 + } 497 + 498 + // Create the file 499 + _, _, err = g.client.Repositories.CreateFile(ctx, owner, repo, path, opts) 500 + if err != nil { 501 + return fmt.Errorf("failed to create file: %w", err) 502 + } 503 + 504 + return nil 505 + } 506 + 507 + // RenameItem renames a file or folder by deleting the old path and creating at the new path 508 + func (g *GitHubConnector) RenameItem(ctx context.Context, owner, repo, oldPath, newPath, message string) error { 509 + if message == "" { 510 + message = fmt.Sprintf("Rename %s to %s", oldPath, newPath) 511 + } 512 + 513 + // Get default branch 514 + repository, _, err := g.client.Repositories.Get(ctx, owner, repo) 515 + if err != nil { 516 + return fmt.Errorf("failed to get repository: %w", err) 517 + } 518 + branch := repository.GetDefaultBranch() 519 + 520 + // Get the content of the old file/path 521 + fileContent, _, _, err := g.client.Repositories.GetContents(ctx, owner, repo, oldPath, &github.RepositoryContentGetOptions{ 522 + Ref: branch, 523 + }) 524 + if err != nil { 525 + return fmt.Errorf("failed to get file content: %w", err) 526 + } 527 + 528 + // If it's a directory, we need to handle all files in it 529 + if fileContent == nil { 530 + // It's a directory - get all files in it 531 + _, directoryContent, _, err := g.client.Repositories.GetContents(ctx, owner, repo, oldPath, &github.RepositoryContentGetOptions{ 532 + Ref: branch, 533 + }) 534 + if err != nil { 535 + return fmt.Errorf("failed to get directory contents: %w", err) 536 + } 537 + 538 + // Rename each file in the directory 539 + for _, item := range directoryContent { 540 + if item.GetType() == "file" { 541 + oldFilePath := item.GetPath() 542 + newFilePath := strings.Replace(oldFilePath, oldPath, newPath, 1) 543 + 544 + // Get file content 545 + content, err := item.GetContent() 546 + if err != nil { 547 + return fmt.Errorf("failed to get content for %s: %w", oldFilePath, err) 548 + } 549 + 550 + // Create new file 551 + encodedContent := base64.StdEncoding.EncodeToString([]byte(content)) 552 + opts := &github.RepositoryContentFileOptions{ 553 + Message: github.String(message), 554 + Content: []byte(encodedContent), 555 + Branch: github.String(branch), 556 + } 557 + _, _, err = g.client.Repositories.CreateFile(ctx, owner, repo, newFilePath, opts) 558 + if err != nil { 559 + return fmt.Errorf("failed to create file %s: %w", newFilePath, err) 560 + } 561 + 562 + // Delete old file 563 + deleteOpts := &github.RepositoryContentFileOptions{ 564 + Message: github.String(message), 565 + SHA: github.String(item.GetSHA()), 566 + Branch: github.String(branch), 567 + } 568 + _, _, err = g.client.Repositories.DeleteFile(ctx, owner, repo, oldFilePath, deleteOpts) 569 + if err != nil { 570 + return fmt.Errorf("failed to delete file %s: %w", oldFilePath, err) 571 + } 572 + } 573 + } 574 + } else { 575 + // It's a single file 576 + content, err := fileContent.GetContent() 577 + if err != nil { 578 + return fmt.Errorf("failed to get file content: %w", err) 579 + } 580 + 581 + // Create new file at new path 582 + encodedContent := base64.StdEncoding.EncodeToString([]byte(content)) 583 + opts := &github.RepositoryContentFileOptions{ 584 + Message: github.String(message), 585 + Content: []byte(encodedContent), 586 + Branch: github.String(branch), 587 + } 588 + _, _, err = g.client.Repositories.CreateFile(ctx, owner, repo, newPath, opts) 589 + if err != nil { 590 + return fmt.Errorf("failed to create file at new path: %w", err) 591 + } 592 + 593 + // Delete old file 594 + deleteOpts := &github.RepositoryContentFileOptions{ 595 + Message: github.String(message), 596 + SHA: github.String(fileContent.GetSHA()), 597 + Branch: github.String(branch), 598 + } 599 + _, _, err = g.client.Repositories.DeleteFile(ctx, owner, repo, oldPath, deleteOpts) 600 + if err != nil { 601 + return fmt.Errorf("failed to delete old file: %w", err) 602 + } 603 + } 604 + 605 + return nil 606 + }
+37
backend/internal/database/migrations/002_add_change_type.sql
··· 1 + -- Migration: Add change_type and original_path to draft_content 2 + -- This allows tracking different types of changes (edit, new_file, new_folder, rename) 3 + -- This migration is idempotent and handles cases where columns might already exist 4 + 5 + -- Create a temporary table with the new schema 6 + CREATE TABLE IF NOT EXISTS draft_content_new ( 7 + id INTEGER PRIMARY KEY AUTOINCREMENT, 8 + user_id INTEGER NOT NULL, 9 + repo_full_name TEXT NOT NULL, 10 + file_path TEXT NOT NULL, 11 + content TEXT NOT NULL, 12 + change_type TEXT DEFAULT 'edit', 13 + original_path TEXT, 14 + last_saved_at DATETIME DEFAULT CURRENT_TIMESTAMP, 15 + UNIQUE(user_id, repo_full_name, file_path), 16 + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE 17 + ); 18 + 19 + -- Copy data from old table if it exists 20 + INSERT OR IGNORE INTO draft_content_new (id, user_id, repo_full_name, file_path, content, change_type, original_path, last_saved_at) 21 + SELECT id, user_id, repo_full_name, file_path, content, 22 + COALESCE(change_type, 'edit') as change_type, 23 + original_path, 24 + last_saved_at 25 + FROM draft_content; 26 + 27 + -- Drop old table 28 + DROP TABLE IF EXISTS draft_content; 29 + 30 + -- Rename new table to old name 31 + ALTER TABLE draft_content_new RENAME TO draft_content; 32 + 33 + -- Create index for efficient querying by change_type 34 + CREATE INDEX IF NOT EXISTS idx_draft_content_change_type ON draft_content(user_id, repo_full_name, change_type); 35 + 36 + -- Create original index if it doesn't exist 37 + CREATE INDEX IF NOT EXISTS idx_draft_content_user_repo_file ON draft_content(user_id, repo_full_name, file_path);
+2
backend/internal/database/models.go
··· 57 57 RepoFullName string `json:"repo_full_name"` 58 58 FilePath string `json:"file_path"` 59 59 Content string `json:"content"` 60 + ChangeType string `json:"change_type"` // 'edit', 'new_file', 'new_folder', 'rename' 61 + OriginalPath string `json:"original_path"` // For renames: old path 60 62 LastSavedAt time.Time `json:"last_saved_at"` 61 63 } 62 64
+61 -3
backend/internal/database/queries.go
··· 140 140 // SaveDraftContent saves or updates draft content for a file 141 141 func (db *DB) SaveDraftContent(draft *DraftContent) error { 142 142 query := ` 143 - INSERT INTO draft_content (user_id, repo_full_name, file_path, content, last_saved_at) 144 - VALUES (?, ?, ?, ?, CURRENT_TIMESTAMP) 143 + INSERT INTO draft_content (user_id, repo_full_name, file_path, content, change_type, original_path, last_saved_at) 144 + VALUES (?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP) 145 145 ON CONFLICT(user_id, repo_full_name, file_path) DO UPDATE SET 146 146 content = excluded.content, 147 + change_type = excluded.change_type, 148 + original_path = excluded.original_path, 147 149 last_saved_at = CURRENT_TIMESTAMP 148 150 RETURNING id, last_saved_at 149 151 ` ··· 153 155 draft.RepoFullName, 154 156 draft.FilePath, 155 157 draft.Content, 158 + draft.ChangeType, 159 + draft.OriginalPath, 156 160 ).Scan(&draft.ID, &draft.LastSavedAt) 157 161 } 158 162 ··· 160 164 func (db *DB) GetDraftContent(userID int, repoFullName, filePath string) (*DraftContent, error) { 161 165 draft := &DraftContent{} 162 166 query := ` 163 - SELECT id, user_id, repo_full_name, file_path, content, last_saved_at 167 + SELECT id, user_id, repo_full_name, file_path, content, change_type, original_path, last_saved_at 164 168 FROM draft_content 165 169 WHERE user_id = ? AND repo_full_name = ? AND file_path = ? 166 170 ` ··· 171 175 &draft.RepoFullName, 172 176 &draft.FilePath, 173 177 &draft.Content, 178 + &draft.ChangeType, 179 + &draft.OriginalPath, 174 180 &draft.LastSavedAt, 175 181 ) 176 182 ··· 194 200 _, err := db.Exec(query, userID, repoFullName, filePath) 195 201 if err != nil { 196 202 return fmt.Errorf("failed to delete draft: %w", err) 203 + } 204 + 205 + return nil 206 + } 207 + 208 + // GetAllDraftContent retrieves all draft content for a repository 209 + func (db *DB) GetAllDraftContent(userID int, repoFullName string) ([]*DraftContent, error) { 210 + query := ` 211 + SELECT id, user_id, repo_full_name, file_path, content, change_type, original_path, last_saved_at 212 + FROM draft_content 213 + WHERE user_id = ? AND repo_full_name = ? 214 + ORDER BY last_saved_at DESC 215 + ` 216 + 217 + rows, err := db.Query(query, userID, repoFullName) 218 + if err != nil { 219 + return nil, fmt.Errorf("failed to get all drafts: %w", err) 220 + } 221 + defer rows.Close() 222 + 223 + var drafts []*DraftContent 224 + for rows.Next() { 225 + draft := &DraftContent{} 226 + err := rows.Scan( 227 + &draft.ID, 228 + &draft.UserID, 229 + &draft.RepoFullName, 230 + &draft.FilePath, 231 + &draft.Content, 232 + &draft.ChangeType, 233 + &draft.OriginalPath, 234 + &draft.LastSavedAt, 235 + ) 236 + if err != nil { 237 + return nil, fmt.Errorf("failed to scan draft row: %w", err) 238 + } 239 + drafts = append(drafts, draft) 240 + } 241 + 242 + return drafts, nil 243 + } 244 + 245 + // DeleteAllDraftContent deletes all draft content for a repository 246 + func (db *DB) DeleteAllDraftContent(userID int, repoFullName string) error { 247 + query := ` 248 + DELETE FROM draft_content 249 + WHERE user_id = ? AND repo_full_name = ? 250 + ` 251 + 252 + _, err := db.Exec(query, userID, repoFullName) 253 + if err != nil { 254 + return fmt.Errorf("failed to delete all drafts: %w", err) 197 255 } 198 256 199 257 return nil
+12
backend/internal/git/operations.go
··· 226 226 227 227 return head.Name().Short(), nil 228 228 } 229 + 230 + // DeleteFile removes a file from the repository worktree 231 + func (g *GitOperations) DeleteFile(repoFullName, filePath string) error { 232 + repoPath := g.getRepoPath(repoFullName) 233 + fullPath := filepath.Join(repoPath, filePath) 234 + 235 + if err := os.Remove(fullPath); err != nil && !os.IsNotExist(err) { 236 + return fmt.Errorf("failed to delete file: %w", err) 237 + } 238 + 239 + return nil 240 + }
+378 -2
frontend/src/components/dashboard/DashboardApp.tsx
··· 1 1 import { useState, useEffect } from 'react'; 2 2 import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; 3 - import { Toaster } from 'sonner'; 3 + import { Toaster, toast } from 'sonner'; 4 4 import { Header } from '../layout/Header'; 5 5 import { FileTree } from './FileTree'; 6 6 import { FileTreeHeader } from './FileTreeHeader'; 7 7 import { EditorContainer } from '../editor/EditorContainer'; 8 - import { useFiles } from '../../lib/hooks/useRepos'; 8 + import { useFiles, useCreateFile, useCreateFolder, useRenameItem, usePendingChanges, useDiscardChanges } from '../../lib/hooks/useRepos'; 9 9 import { useCurrentUser, useUserRepos } from '../../lib/hooks/useAuth'; 10 + import { useBranchStatus, usePublish } from '../../lib/hooks/useBranch'; 10 11 import { Loading } from '../ui/Loading'; 11 12 import { EmptyState } from '../ui/EmptyState'; 12 13 ··· 48 49 const { data: user, isLoading: userLoading } = useCurrentUser(); 49 50 const { data: userRepos, isLoading: reposLoading } = useUserRepos(); 50 51 52 + // Modal states for creating files/folders 53 + const [createModalOpen, setCreateModalOpen] = useState(false); 54 + const [createModalType, setCreateModalType] = useState<'file' | 'folder'>('file'); 55 + const [createModalPath, setCreateModalPath] = useState(''); 56 + const [newItemName, setNewItemName] = useState(''); 57 + 58 + // Mutations 59 + const createFileMutation = useCreateFile(repoConfig?.owner || '', repoConfig?.repo || ''); 60 + const createFolderMutation = useCreateFolder(repoConfig?.owner || '', repoConfig?.repo || ''); 61 + const renameItemMutation = useRenameItem(repoConfig?.owner || '', repoConfig?.repo || ''); 62 + const publishMutation = usePublish(); 63 + const discardChangesMutation = useDiscardChanges(repoConfig?.owner || '', repoConfig?.repo || ''); 64 + 65 + // Check for pending changes 66 + const { data: pendingChanges } = usePendingChanges( 67 + repoConfig?.owner || '', 68 + repoConfig?.repo || '' 69 + ); 70 + const { data: branchStatus } = useBranchStatus( 71 + repoConfig?.owner || '', 72 + repoConfig?.repo || '' 73 + ); 74 + 75 + // Dialog states 76 + const [publishDialogOpen, setPublishDialogOpen] = useState(false); 77 + const [discardDialogOpen, setDiscardDialogOpen] = useState(false); 78 + const [commitMessage, setCommitMessage] = useState(''); 79 + const [prTitle, setPrTitle] = useState(''); 80 + 81 + // Determine if there are pending changes 82 + const hasPendingChanges = pendingChanges && pendingChanges.count > 0; 83 + 51 84 // Check for repo from URL params, user.last_repo, or user_repos table 52 85 useEffect(() => { 53 86 if (userLoading || reposLoading || !user) { ··· 105 138 ['md', 'mdx'] 106 139 ); 107 140 141 + // Handlers for file operations 142 + const handleCreateFile = (parentPath: string) => { 143 + setCreateModalType('file'); 144 + setCreateModalPath(parentPath); 145 + setNewItemName(''); 146 + setCreateModalOpen(true); 147 + }; 148 + 149 + const handleCreateFolder = (parentPath: string) => { 150 + setCreateModalType('folder'); 151 + setCreateModalPath(parentPath); 152 + setNewItemName(''); 153 + setCreateModalOpen(true); 154 + }; 155 + 156 + const handleCreateSubmit = async () => { 157 + if (!newItemName.trim() || !repoConfig) return; 158 + 159 + // Replace spaces with hyphens 160 + const sanitizedName = newItemName.trim().replace(/\s+/g, '-'); 161 + 162 + const fullPath = createModalPath 163 + ? `${createModalPath}/${sanitizedName}` 164 + : sanitizedName; 165 + 166 + try { 167 + if (createModalType === 'file') { 168 + // Ensure file has .md or .mdx extension 169 + let filePath = fullPath; 170 + if (!filePath.endsWith('.md') && !filePath.endsWith('.mdx')) { 171 + filePath += '.md'; 172 + } 173 + await createFileMutation.mutateAsync({ path: filePath, content: '', message: `Create ${filePath}` }); 174 + toast.success(`File "${filePath}" created successfully`); 175 + } else { 176 + await createFolderMutation.mutateAsync({ path: fullPath, message: `Create folder ${fullPath}` }); 177 + toast.success(`Folder "${fullPath}" created successfully`); 178 + } 179 + setCreateModalOpen(false); 180 + setNewItemName(''); 181 + } catch (error) { 182 + toast.error(`Failed to create ${createModalType}: ${error instanceof Error ? error.message : 'Unknown error'}`); 183 + } 184 + }; 185 + 186 + const handleRename = async (oldPath: string, newName: string) => { 187 + if (!repoConfig || !newName.trim() || newName === oldPath.split('/').pop()) return; 188 + 189 + // Ensure .md extension for files if not present 190 + let finalName = newName.trim(); 191 + if (!finalName.endsWith('.md') && !finalName.endsWith('.mdx')) { 192 + finalName += '.md'; 193 + } 194 + 195 + const pathParts = oldPath.split('/'); 196 + pathParts[pathParts.length - 1] = finalName; 197 + const newPath = pathParts.join('/'); 198 + 199 + try { 200 + await renameItemMutation.mutateAsync({ 201 + oldPath, 202 + newPath, 203 + message: `Rename ${oldPath} to ${newPath}`, 204 + }); 205 + toast.success(`Renamed to "${newName}" successfully`); 206 + 207 + // If the renamed file was selected, update the selected file 208 + if (selectedFile === oldPath) { 209 + setSelectedFile(newPath); 210 + } 211 + } catch (error) { 212 + toast.error(`Failed to rename: ${error instanceof Error ? error.message : 'Unknown error'}`); 213 + } 214 + }; 215 + 216 + const handlePublish = async () => { 217 + if (!repoConfig || !commitMessage.trim()) return; 218 + 219 + try { 220 + await publishMutation.mutateAsync({ 221 + owner: repoConfig.owner, 222 + repo: repoConfig.repo, 223 + commit_message: commitMessage, 224 + pr_title: prTitle.trim() || commitMessage, 225 + }); 226 + toast.success('Changes published successfully!'); 227 + setPublishDialogOpen(false); 228 + setCommitMessage(''); 229 + setPrTitle(''); 230 + } catch (error) { 231 + toast.error(`Failed to publish: ${error instanceof Error ? error.message : 'Unknown error'}`); 232 + } 233 + }; 234 + 235 + const handleDiscard = async () => { 236 + if (!repoConfig) return; 237 + 238 + try { 239 + await discardChangesMutation.mutateAsync(); 240 + toast.success('All pending changes have been discarded'); 241 + setDiscardDialogOpen(false); 242 + } catch (error) { 243 + toast.error(`Failed to discard changes: ${error instanceof Error ? error.message : 'Unknown error'}`); 244 + } 245 + }; 246 + 108 247 if (userLoading || reposLoading) { 109 248 return ( 110 249 <div className="min-h-screen bg-gray-50 flex items-center justify-center"> ··· 174 313 files={filesData.files} 175 314 selectedFile={selectedFile} 176 315 onSelectFile={setSelectedFile} 316 + owner={repoConfig.owner} 317 + repo={repoConfig.repo} 318 + onCreateFile={handleCreateFile} 319 + onCreateFolder={handleCreateFolder} 320 + onRename={handleRename} 177 321 /> 178 322 ) : ( 179 323 <div className="p-4"> ··· 239 383 )} 240 384 </main> 241 385 </div> 386 + 387 + {/* Create File/Folder Modal */} 388 + {createModalOpen && ( 389 + <div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50"> 390 + <div className="bg-white rounded-lg shadow-xl p-6 w-full max-w-md"> 391 + <h3 className="text-lg font-semibold text-gray-900 mb-4"> 392 + Create New {createModalType === 'file' ? 'File' : 'Folder'} 393 + </h3> 394 + <p className="text-sm text-gray-600 mb-4"> 395 + in {createModalPath || 'root'} 396 + </p> 397 + <input 398 + type="text" 399 + value={newItemName} 400 + onChange={(e) => setNewItemName(e.target.value)} 401 + onKeyDown={(e) => { 402 + if (e.key === 'Enter') handleCreateSubmit(); 403 + if (e.key === 'Escape') setCreateModalOpen(false); 404 + }} 405 + placeholder={createModalType === 'file' ? 'filename.md' : 'folder-name'} 406 + className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-gray-900" 407 + autoFocus 408 + /> 409 + <div className="flex justify-end gap-2 mt-6"> 410 + <button 411 + onClick={() => setCreateModalOpen(false)} 412 + className="px-4 py-2 text-sm text-gray-600 hover:text-gray-900" 413 + > 414 + Cancel 415 + </button> 416 + <button 417 + onClick={handleCreateSubmit} 418 + disabled={!newItemName.trim() || createFileMutation.isPending || createFolderMutation.isPending} 419 + className="px-4 py-2 text-sm bg-gray-900 text-white rounded-md hover:bg-gray-800 disabled:opacity-50 disabled:cursor-not-allowed" 420 + > 421 + {createFileMutation.isPending || createFolderMutation.isPending 422 + ? 'Creating...' 423 + : 'Create'} 424 + </button> 425 + </div> 426 + </div> 427 + </div> 428 + )} 429 + 430 + {/* Global Publish/Discard Buttons - shows when there are pending changes */} 431 + {hasPendingChanges && ( 432 + <div className="fixed bottom-6 right-6 z-40 flex items-center gap-3"> 433 + {/* Discard Button */} 434 + <button 435 + onClick={() => setDiscardDialogOpen(true)} 436 + disabled={discardChangesMutation.isPending} 437 + className="flex items-center gap-2 px-4 py-3 bg-white text-red-600 border border-red-200 rounded-full shadow-lg hover:bg-red-50 disabled:opacity-50 disabled:cursor-not-allowed transition-all" 438 + title="Discard all pending changes" 439 + > 440 + <svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor"> 441 + <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" /> 442 + </svg> 443 + <span className="font-medium"> 444 + {discardChangesMutation.isPending ? 'Discarding...' : 'Discard'} 445 + </span> 446 + </button> 447 + 448 + {/* Publish Button */} 449 + <button 450 + onClick={() => setPublishDialogOpen(true)} 451 + disabled={publishMutation.isPending} 452 + className="flex items-center gap-2 px-6 py-3 bg-gray-900 text-white rounded-full shadow-lg hover:bg-gray-800 disabled:opacity-50 disabled:cursor-not-allowed transition-all hover:scale-105" 453 + > 454 + <svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor"> 455 + <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12" /> 456 + </svg> 457 + <span className="font-medium"> 458 + {publishMutation.isPending ? 'Publishing...' : `Publish Changes (${pendingChanges?.count})`} 459 + </span> 460 + </button> 461 + </div> 462 + )} 463 + 464 + {/* Publish Dialog */} 465 + {publishDialogOpen && ( 466 + <div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50"> 467 + <div className="bg-white rounded-lg shadow-xl p-6 w-full max-w-md"> 468 + <h3 className="text-lg font-semibold text-gray-900 mb-2"> 469 + Publish Changes 470 + </h3> 471 + <p className="text-sm text-gray-600 mb-4"> 472 + {pendingChanges?.count} pending change(s) will be committed and a pull request will be created. 473 + </p> 474 + 475 + <div className="space-y-4"> 476 + <div> 477 + <label className="block text-sm font-medium text-gray-700 mb-1"> 478 + Commit Message * 479 + </label> 480 + <input 481 + type="text" 482 + value={commitMessage} 483 + onChange={(e) => setCommitMessage(e.target.value)} 484 + onKeyDown={(e) => { 485 + if (e.key === 'Enter' && commitMessage.trim()) handlePublish(); 486 + if (e.key === 'Escape') setPublishDialogOpen(false); 487 + }} 488 + placeholder="e.g., Update blog posts" 489 + className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-gray-900" 490 + autoFocus 491 + /> 492 + </div> 493 + 494 + <div> 495 + <label className="block text-sm font-medium text-gray-700 mb-1"> 496 + Pull Request Title (optional) 497 + </label> 498 + <input 499 + type="text" 500 + value={prTitle} 501 + onChange={(e) => setPrTitle(e.target.value)} 502 + placeholder="Defaults to commit message" 503 + className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-gray-900" 504 + /> 505 + </div> 506 + 507 + {pendingChanges && pendingChanges.pending_changes && pendingChanges.pending_changes.length > 0 && ( 508 + <div className="bg-gray-50 rounded-md p-3"> 509 + <p className="text-xs font-medium text-gray-700 mb-2">Changed files:</p> 510 + <ul className="text-xs text-gray-600 space-y-1 max-h-32 overflow-y-auto"> 511 + {pendingChanges.pending_changes.map((change: any) => ( 512 + <li key={change.id} className="flex items-center gap-2"> 513 + <span className={` 514 + px-1.5 py-0.5 rounded text-[10px] font-medium uppercase 515 + ${change.change_type === 'new_file' ? 'bg-green-100 text-green-700' : ''} 516 + ${change.change_type === 'new_folder' ? 'bg-blue-100 text-blue-700' : ''} 517 + ${change.change_type === 'rename' ? 'bg-yellow-100 text-yellow-700' : ''} 518 + ${change.change_type === 'edit' ? 'bg-gray-100 text-gray-700' : ''} 519 + `}> 520 + {change.change_type === 'new_file' && 'New'} 521 + {change.change_type === 'new_folder' && 'Folder'} 522 + {change.change_type === 'rename' && 'Renamed'} 523 + {change.change_type === 'edit' && 'Edit'} 524 + </span> 525 + <span className="truncate">{change.file_path}</span> 526 + </li> 527 + ))} 528 + </ul> 529 + </div> 530 + )} 531 + </div> 532 + 533 + <div className="flex justify-end gap-2 mt-6"> 534 + <button 535 + onClick={() => { 536 + setPublishDialogOpen(false); 537 + setCommitMessage(''); 538 + setPrTitle(''); 539 + }} 540 + className="px-4 py-2 text-sm text-gray-600 hover:text-gray-900" 541 + > 542 + Cancel 543 + </button> 544 + <button 545 + onClick={handlePublish} 546 + disabled={!commitMessage.trim() || publishMutation.isPending} 547 + className="px-4 py-2 text-sm bg-gray-900 text-white rounded-md hover:bg-gray-800 disabled:opacity-50 disabled:cursor-not-allowed" 548 + > 549 + {publishMutation.isPending ? 'Publishing...' : 'Publish'} 550 + </button> 551 + </div> 552 + </div> 553 + </div> 554 + )} 555 + 556 + {/* Discard Confirmation Dialog */} 557 + {discardDialogOpen && ( 558 + <div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50"> 559 + <div className="bg-white rounded-lg shadow-xl p-6 w-full max-w-md"> 560 + <div className="flex items-center gap-3 mb-4"> 561 + <div className="p-2 bg-red-100 rounded-full"> 562 + <svg className="w-6 h-6 text-red-600" fill="none" viewBox="0 0 24 24" stroke="currentColor"> 563 + <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" /> 564 + </svg> 565 + </div> 566 + <h3 className="text-lg font-semibold text-gray-900"> 567 + Discard All Changes? 568 + </h3> 569 + </div> 570 + 571 + <p className="text-sm text-gray-600 mb-4"> 572 + This will permanently delete all {pendingChanges?.count} pending change(s). This action cannot be undone. 573 + </p> 574 + 575 + {pendingChanges && pendingChanges.pending_changes && pendingChanges.pending_changes.length > 0 && ( 576 + <div className="bg-gray-50 rounded-md p-3 mb-4"> 577 + <p className="text-xs font-medium text-gray-700 mb-2">Changes to be discarded:</p> 578 + <ul className="text-xs text-gray-600 space-y-1 max-h-32 overflow-y-auto"> 579 + {pendingChanges.pending_changes.map((change: any) => ( 580 + <li key={change.id} className="flex items-center gap-2"> 581 + <span className={` 582 + px-1.5 py-0.5 rounded text-[10px] font-medium uppercase 583 + ${change.change_type === 'new_file' ? 'bg-green-100 text-green-700' : ''} 584 + ${change.change_type === 'new_folder' ? 'bg-blue-100 text-blue-700' : ''} 585 + ${change.change_type === 'rename' ? 'bg-yellow-100 text-yellow-700' : ''} 586 + ${change.change_type === 'edit' ? 'bg-gray-100 text-gray-700' : ''} 587 + `}> 588 + {change.change_type === 'new_file' && 'New'} 589 + {change.change_type === 'new_folder' && 'Folder'} 590 + {change.change_type === 'rename' && 'Renamed'} 591 + {change.change_type === 'edit' && 'Edit'} 592 + </span> 593 + <span className="truncate">{change.file_path}</span> 594 + </li> 595 + ))} 596 + </ul> 597 + </div> 598 + )} 599 + 600 + <div className="flex justify-end gap-2"> 601 + <button 602 + onClick={() => setDiscardDialogOpen(false)} 603 + className="px-4 py-2 text-sm text-gray-600 hover:text-gray-900" 604 + > 605 + Cancel 606 + </button> 607 + <button 608 + onClick={handleDiscard} 609 + disabled={discardChangesMutation.isPending} 610 + className="px-4 py-2 text-sm bg-red-600 text-white rounded-md hover:bg-red-700 disabled:opacity-50 disabled:cursor-not-allowed" 611 + > 612 + {discardChangesMutation.isPending ? 'Discarding...' : 'Discard All Changes'} 613 + </button> 614 + </div> 615 + </div> 616 + </div> 617 + )} 242 618 </div> 243 619 ); 244 620 }
+236 -36
frontend/src/components/dashboard/FileTree.tsx
··· 1 - import { useState } from 'react'; 1 + import { useState, useRef, useEffect } from 'react'; 2 2 import type { FileNode } from '../../lib/types/api'; 3 3 4 4 interface FileTreeProps { 5 5 files: FileNode[]; 6 6 selectedFile: string | null; 7 7 onSelectFile: (path: string) => void; 8 + owner: string; 9 + repo: string; 10 + onCreateFile?: (parentPath: string) => void; 11 + onCreateFolder?: (parentPath: string) => void; 12 + onRename?: (oldPath: string, newName: string) => void; 8 13 } 9 14 10 - export function FileTree({ files, selectedFile, onSelectFile }: FileTreeProps) { 15 + export function FileTree({ 16 + files, 17 + selectedFile, 18 + onSelectFile, 19 + owner, 20 + repo, 21 + onCreateFile, 22 + onCreateFolder, 23 + onRename, 24 + }: FileTreeProps) { 11 25 return ( 12 26 <div className="p-4 space-y-1"> 13 27 {files.length === 0 ? ( ··· 22 36 selectedFile={selectedFile} 23 37 onSelectFile={onSelectFile} 24 38 level={0} 39 + owner={owner} 40 + repo={repo} 41 + onCreateFile={onCreateFile} 42 + onCreateFolder={onCreateFolder} 43 + onRename={onRename} 25 44 /> 26 45 )) 27 46 )} ··· 34 53 selectedFile: string | null; 35 54 onSelectFile: (path: string) => void; 36 55 level: number; 56 + owner: string; 57 + repo: string; 58 + onCreateFile?: (parentPath: string) => void; 59 + onCreateFolder?: (parentPath: string) => void; 60 + onRename?: (oldPath: string, newName: string) => void; 37 61 } 38 62 39 - function FileTreeNode({ node, selectedFile, onSelectFile, level }: FileTreeNodeProps) { 63 + function FileTreeNode({ 64 + node, 65 + selectedFile, 66 + onSelectFile, 67 + level, 68 + owner, 69 + repo, 70 + onCreateFile, 71 + onCreateFolder, 72 + onRename, 73 + }: FileTreeNodeProps) { 40 74 const [isExpanded, setIsExpanded] = useState(true); 75 + const [showActions, setShowActions] = useState(false); 76 + const [isRenaming, setIsRenaming] = useState(false); 77 + const [renameValue, setRenameValue] = useState(node.name); 78 + const renameInputRef = useRef<HTMLInputElement>(null); 41 79 const isSelected = selectedFile === node.path; 42 80 81 + useEffect(() => { 82 + if (isRenaming && renameInputRef.current) { 83 + renameInputRef.current.focus(); 84 + renameInputRef.current.select(); 85 + } 86 + }, [isRenaming]); 87 + 88 + const handleRenameSubmit = () => { 89 + if (renameValue.trim() && renameValue !== node.name && onRename) { 90 + // Replace spaces with hyphens 91 + let sanitizedName = renameValue.trim().replace(/\s+/g, '-'); 92 + 93 + // For files, ensure .md extension if not present 94 + if (node.type === 'file' && !sanitizedName.endsWith('.md') && !sanitizedName.endsWith('.mdx')) { 95 + sanitizedName += '.md'; 96 + } 97 + 98 + onRename(node.path, sanitizedName); 99 + } 100 + setIsRenaming(false); 101 + setRenameValue(node.name); 102 + }; 103 + 104 + const handleRenameCancel = () => { 105 + setIsRenaming(false); 106 + setRenameValue(node.name); 107 + }; 108 + 109 + const handleKeyDown = (e: React.KeyboardEvent) => { 110 + if (e.key === 'Enter') { 111 + handleRenameSubmit(); 112 + } else if (e.key === 'Escape') { 113 + handleRenameCancel(); 114 + } 115 + }; 116 + 43 117 if (node.type === 'file') { 44 118 return ( 45 - <button 46 - onClick={() => onSelectFile(node.path)} 47 - className={` 48 - w-full text-left px-3 py-2 rounded text-sm flex items-center gap-2 49 - transition-colors 50 - ${isSelected 51 - ? 'bg-gray-900 text-white font-medium' 52 - : 'text-gray-700 hover:bg-gray-100' 53 - } 54 - `} 55 - style={{ paddingLeft: `${(level + 1) * 12 + 12}px` }} 119 + <div 120 + className="group relative" 121 + onMouseEnter={() => setShowActions(true)} 122 + onMouseLeave={() => setShowActions(false)} 56 123 > 57 - <svg className="w-4 h-4 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor"> 58 - <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" /> 59 - </svg> 60 - <span className="truncate">{node.name}</span> 61 - </button> 124 + {isRenaming ? ( 125 + <div 126 + className="flex items-center gap-2 px-3 py-2" 127 + style={{ paddingLeft: `${(level + 1) * 12 + 12}px` }} 128 + > 129 + <svg className="w-4 h-4 flex-shrink-0 text-gray-500" fill="none" viewBox="0 0 24 24" stroke="currentColor"> 130 + <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" /> 131 + </svg> 132 + <input 133 + ref={renameInputRef} 134 + type="text" 135 + value={renameValue} 136 + onChange={(e) => setRenameValue(e.target.value)} 137 + onKeyDown={handleKeyDown} 138 + onBlur={handleRenameSubmit} 139 + className="flex-1 px-2 py-1 text-sm border border-gray-300 rounded focus:outline-none focus:ring-2 focus:ring-gray-900" 140 + /> 141 + </div> 142 + ) : ( 143 + <button 144 + onClick={() => onSelectFile(node.path)} 145 + className={` 146 + w-full text-left px-3 py-2 rounded text-sm flex items-center gap-2 147 + transition-colors 148 + ${isSelected 149 + ? 'bg-gray-900 text-white font-medium' 150 + : 'text-gray-700 hover:bg-gray-100' 151 + } 152 + `} 153 + style={{ paddingLeft: `${(level + 1) * 12 + 12}px` }} 154 + > 155 + <svg className="w-4 h-4 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor"> 156 + <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" /> 157 + </svg> 158 + <span className="truncate">{node.name}</span> 159 + </button> 160 + )} 161 + 162 + {/* Actions menu for files */} 163 + {showActions && !isRenaming && ( 164 + <div className="absolute right-2 top-1/2 -translate-y-1/2 flex items-center gap-1"> 165 + <button 166 + onClick={(e) => { 167 + e.stopPropagation(); 168 + setIsRenaming(true); 169 + }} 170 + className="p-1 rounded hover:bg-gray-200 text-gray-500 hover:text-gray-700" 171 + title="Rename file" 172 + > 173 + <svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor"> 174 + <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" /> 175 + </svg> 176 + </button> 177 + </div> 178 + )} 179 + </div> 62 180 ); 63 181 } 64 182 65 183 return ( 66 184 <div> 67 - <button 68 - onClick={() => setIsExpanded(!isExpanded)} 69 - className="w-full text-left px-3 py-2 rounded text-sm flex items-center gap-2 text-gray-700 hover:bg-gray-100 font-medium" 70 - style={{ paddingLeft: `${level * 12 + 12}px` }} 185 + <div 186 + className="group relative" 187 + onMouseEnter={() => setShowActions(true)} 188 + onMouseLeave={() => setShowActions(false)} 71 189 > 72 - <svg 73 - className={`w-4 h-4 flex-shrink-0 transition-transform ${isExpanded ? 'rotate-90' : ''}`} 74 - fill="none" 75 - viewBox="0 0 24 24" 76 - stroke="currentColor" 77 - > 78 - <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" /> 79 - </svg> 80 - <svg className="w-4 h-4 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor"> 81 - <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z" /> 82 - </svg> 83 - <span className="truncate">{node.name}</span> 84 - </button> 190 + {isRenaming ? ( 191 + <div 192 + className="flex items-center gap-2 px-3 py-2" 193 + style={{ paddingLeft: `${level * 12 + 12}px` }} 194 + > 195 + <svg 196 + className={`w-4 h-4 flex-shrink-0 text-gray-500 transition-transform ${isExpanded ? 'rotate-90' : ''}`} 197 + fill="none" 198 + viewBox="0 0 24 24" 199 + stroke="currentColor" 200 + > 201 + <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" /> 202 + </svg> 203 + <svg className="w-4 h-4 flex-shrink-0 text-gray-500" fill="none" viewBox="0 0 24 24" stroke="currentColor"> 204 + <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z" /> 205 + </svg> 206 + <input 207 + ref={renameInputRef} 208 + type="text" 209 + value={renameValue} 210 + onChange={(e) => setRenameValue(e.target.value)} 211 + onKeyDown={handleKeyDown} 212 + onBlur={handleRenameSubmit} 213 + className="flex-1 px-2 py-1 text-sm border border-gray-300 rounded focus:outline-none focus:ring-2 focus:ring-gray-900" 214 + /> 215 + </div> 216 + ) : ( 217 + <button 218 + onClick={() => setIsExpanded(!isExpanded)} 219 + className="w-full text-left px-3 py-2 rounded text-sm flex items-center gap-2 text-gray-700 hover:bg-gray-100 font-medium" 220 + style={{ paddingLeft: `${level * 12 + 12}px` }} 221 + > 222 + <svg 223 + className={`w-4 h-4 flex-shrink-0 transition-transform ${isExpanded ? 'rotate-90' : ''}`} 224 + fill="none" 225 + viewBox="0 0 24 24" 226 + stroke="currentColor" 227 + > 228 + <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" /> 229 + </svg> 230 + <svg className="w-4 h-4 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor"> 231 + <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z" /> 232 + </svg> 233 + <span className="truncate">{node.name}</span> 234 + </button> 235 + )} 236 + 237 + {/* Actions menu for folders */} 238 + {showActions && !isRenaming && ( 239 + <div className="absolute right-2 top-1/2 -translate-y-1/2 flex items-center gap-1"> 240 + <button 241 + onClick={(e) => { 242 + e.stopPropagation(); 243 + onCreateFile?.(node.path); 244 + }} 245 + className="p-1 rounded hover:bg-gray-200 text-gray-500 hover:text-gray-700" 246 + title="Add file" 247 + > 248 + <svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor"> 249 + <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" /> 250 + </svg> 251 + </button> 252 + <button 253 + onClick={(e) => { 254 + e.stopPropagation(); 255 + onCreateFolder?.(node.path); 256 + }} 257 + className="p-1 rounded hover:bg-gray-200 text-gray-500 hover:text-gray-700" 258 + title="Add folder" 259 + > 260 + <svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor"> 261 + <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z" /> 262 + </svg> 263 + </button> 264 + <button 265 + onClick={(e) => { 266 + e.stopPropagation(); 267 + setIsRenaming(true); 268 + }} 269 + className="p-1 rounded hover:bg-gray-200 text-gray-500 hover:text-gray-700" 270 + title="Rename folder" 271 + > 272 + <svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor"> 273 + <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" /> 274 + </svg> 275 + </button> 276 + </div> 277 + )} 278 + </div> 279 + 85 280 {isExpanded && node.children && ( 86 281 <div> 87 282 {node.children.map((child) => ( ··· 91 286 selectedFile={selectedFile} 92 287 onSelectFile={onSelectFile} 93 288 level={level + 1} 289 + owner={owner} 290 + repo={repo} 291 + onCreateFile={onCreateFile} 292 + onCreateFolder={onCreateFolder} 293 + onRename={onRename} 94 294 /> 95 295 ))} 96 296 </div>
+1 -1
frontend/src/lib/api/branch.ts
··· 16 16 commit_message: string; 17 17 pr_title?: string; 18 18 pr_description?: string; 19 - files: string[]; 19 + files?: string[]; 20 20 } 21 21 22 22 export interface PullRequest {
+61
frontend/src/lib/api/repos.ts
··· 45 45 const { data } = await apiClient.delete<{ invalidated_count: number }>(`/api/repos/${owner}/${repo}/cache`); 46 46 return data; 47 47 }, 48 + 49 + createFile: async ( 50 + owner: string, 51 + repo: string, 52 + path: string, 53 + content: string = '', 54 + message?: string 55 + ): Promise<{ success: boolean; path: string }> => { 56 + const { data } = await apiClient.post<{ success: boolean; path: string }>( 57 + `/api/repos/${owner}/${repo}/create-file`, 58 + { path, content, message } 59 + ); 60 + return data; 61 + }, 62 + 63 + createFolder: async ( 64 + owner: string, 65 + repo: string, 66 + path: string, 67 + message?: string 68 + ): Promise<{ success: boolean; path: string }> => { 69 + const { data } = await apiClient.post<{ success: boolean; path: string }>( 70 + `/api/repos/${owner}/${repo}/create-folder`, 71 + { path, message } 72 + ); 73 + return data; 74 + }, 75 + 76 + renameItem: async ( 77 + owner: string, 78 + repo: string, 79 + oldPath: string, 80 + newPath: string, 81 + message?: string 82 + ): Promise<{ success: boolean; old_path: string; new_path: string }> => { 83 + const { data } = await apiClient.post<{ success: boolean; old_path: string; new_path: string }>( 84 + `/api/repos/${owner}/${repo}/rename`, 85 + { old_path: oldPath, new_path: newPath, message } 86 + ); 87 + return data; 88 + }, 89 + 90 + getPendingChanges: async ( 91 + owner: string, 92 + repo: string 93 + ): Promise<{ pending_changes: any[]; count: number }> => { 94 + const { data } = await apiClient.get<{ pending_changes: any[]; count: number }>( 95 + `/api/repos/${owner}/${repo}/pending-changes` 96 + ); 97 + return data; 98 + }, 99 + 100 + discardChanges: async ( 101 + owner: string, 102 + repo: string 103 + ): Promise<{ success: boolean; message: string }> => { 104 + const { data } = await apiClient.delete<{ success: boolean; message: string }>( 105 + `/api/repos/${owner}/${repo}/pending-changes` 106 + ); 107 + return data; 108 + }, 48 109 };
+7 -1
frontend/src/lib/hooks/useBranch.ts
··· 16 16 return useMutation({ 17 17 mutationFn: (params: PublishParams) => branchApi.publish(params), 18 18 onSuccess: (_, variables) => { 19 - // Invalidate branch status and file content queries 19 + // Invalidate branch status, file content, and pending changes queries 20 20 queryClient.invalidateQueries({ 21 21 queryKey: ['branchStatus', variables.owner, variables.repo], 22 22 }); 23 23 queryClient.invalidateQueries({ 24 24 queryKey: ['fileContent'], 25 + }); 26 + queryClient.invalidateQueries({ 27 + queryKey: ['pending-changes', variables.owner, variables.repo], 28 + }); 29 + queryClient.invalidateQueries({ 30 + queryKey: ['files', variables.owner, variables.repo], 25 31 }); 26 32 }, 27 33 });
+66 -1
frontend/src/lib/hooks/useRepos.ts
··· 1 - import { useQuery } from '@tanstack/react-query'; 1 + import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; 2 2 import { reposApi } from '../api'; 3 3 4 4 export function useRepositories(sortBy: 'updated' | 'created' | 'name' = 'updated') { ··· 39 39 enabled: !!owner && !!repo && !!path, 40 40 }); 41 41 } 42 + 43 + export function useCreateFile(owner: string, repo: string) { 44 + const queryClient = useQueryClient(); 45 + 46 + return useMutation({ 47 + mutationFn: ({ path, content, message }: { path: string; content?: string; message?: string }) => 48 + reposApi.createFile(owner, repo, path, content, message), 49 + onSuccess: () => { 50 + // Invalidate and refetch files query and pending changes 51 + queryClient.invalidateQueries({ queryKey: ['files', owner, repo] }); 52 + queryClient.invalidateQueries({ queryKey: ['pending-changes', owner, repo] }); 53 + }, 54 + }); 55 + } 56 + 57 + export function useCreateFolder(owner: string, repo: string) { 58 + const queryClient = useQueryClient(); 59 + 60 + return useMutation({ 61 + mutationFn: ({ path, message }: { path: string; message?: string }) => 62 + reposApi.createFolder(owner, repo, path, message), 63 + onSuccess: () => { 64 + // Invalidate and refetch files query and pending changes 65 + queryClient.invalidateQueries({ queryKey: ['files', owner, repo] }); 66 + queryClient.invalidateQueries({ queryKey: ['pending-changes', owner, repo] }); 67 + }, 68 + }); 69 + } 70 + 71 + export function useRenameItem(owner: string, repo: string) { 72 + const queryClient = useQueryClient(); 73 + 74 + return useMutation({ 75 + mutationFn: ({ oldPath, newPath, message }: { oldPath: string; newPath: string; message?: string }) => 76 + reposApi.renameItem(owner, repo, oldPath, newPath, message), 77 + onSuccess: () => { 78 + // Invalidate and refetch files query and pending changes 79 + queryClient.invalidateQueries({ queryKey: ['files', owner, repo] }); 80 + queryClient.invalidateQueries({ queryKey: ['pending-changes', owner, repo] }); 81 + }, 82 + }); 83 + } 84 + 85 + export function usePendingChanges(owner: string, repo: string) { 86 + return useQuery({ 87 + queryKey: ['pending-changes', owner, repo], 88 + queryFn: () => reposApi.getPendingChanges(owner, repo), 89 + enabled: !!owner && !!repo, 90 + refetchInterval: 5000, // Refetch every 5 seconds to keep in sync 91 + }); 92 + } 93 + 94 + export function useDiscardChanges(owner: string, repo: string) { 95 + const queryClient = useQueryClient(); 96 + 97 + return useMutation({ 98 + mutationFn: () => reposApi.discardChanges(owner, repo), 99 + onSuccess: () => { 100 + // Invalidate and refetch all related queries 101 + queryClient.invalidateQueries({ queryKey: ['pending-changes', owner, repo] }); 102 + queryClient.invalidateQueries({ queryKey: ['files', owner, repo] }); 103 + queryClient.invalidateQueries({ queryKey: ['branchStatus', owner, repo] }); 104 + }, 105 + }); 106 + }