diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..70aa504 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,20 @@ + +## Knowledge Base (Brain Implant) + +This project uses [brain-implant](https://github.com/jalexray/Brain-Implant) for knowledge management. + +### Brain Structure +- Brain root: `docs/` +- Session logs: `docs/sessions/` +- Decision records: `docs/decisions/` +- Development history: `docs/history.md` +- Config: `brain.yaml` + +### Session Workflow +- `/checkin` — Load context from previous session +- `/checkout` — Document session + capture decisions + doc staleness check + +### Doc Maintenance +- `/update-brain` — Scan changes, update stale docs, sync memories, append to history +- `/audit-docs` — Full documentation health audit with freshness grading + diff --git a/brain.yaml b/brain.yaml new file mode 100644 index 0000000..ebe1512 --- /dev/null +++ b/brain.yaml @@ -0,0 +1,65 @@ +project: + name: "docugit" + description: "" + repo: "jalexray/docugit" + +paths: + brain_root: "docs" + session_logs: "docs/sessions" + decisions: "docs/decisions" + sprints: "docs/sprints" # null if no sprints + reports: + daily: "docs/reports/daily" # null to disable + weekly: "docs/reports/weekly" # null to disable + +team: + members: [] + audience: "" # Who reads reports (e.g., "engineering team") + +sprints: + naming: "v{N}-{slug}" # v1-POC, sprint-1-launch, etc. + +reports: + daily: + enabled: false + data_sources: [] + # Example pluggable data source: + # - name: "API Health" + # type: "shell" + # command: "curl -s https://myapp.com/api/health" + weekly: + enabled: false + tone: "casual-collegial" # casual-collegial | professional | technical + signoff: "" + +staleness: + warn_days: 14 + critical_days: 30 + ignore_patterns: + - "**/reports/daily/*" + - "**/reports/weekly/*" + - "**/sessions/*" + - "**/.gitkeep" + +memory: + sync_enabled: true + promote_types: + - project + - reference + - feedback + +schedule: + audit: + enabled: false + cron: "0 18 * * 0" # Weekly Sunday 6pm + action: "/audit-docs" + on_stale: "log-only" # github-issue | slack | email | log-only + reflection: + enabled: false + cron: "0 9 * * 1" # Monday 9am + action: "/update-brain" + auto_apply: false + +# Reserved for future typed integrations (PostHog, Stripe, Linear, etc.) +# See README for data_sources recipes that work today. +integrations: {} diff --git a/docs/.brain-state.json b/docs/.brain-state.json new file mode 100644 index 0000000..7b7c00d --- /dev/null +++ b/docs/.brain-state.json @@ -0,0 +1,14 @@ +{ + "schema_version": 1, + "last_update_brain_run": "2026-05-20T00:00:00-07:00", + "last_update_brain_commit": "d1d1cf0e76e3be7a8b0d00c0f80195a5bb7d98f9", + "last_audit_run": "2026-05-21T00:00:00-07:00", + "audit_results": { + "grade": "A", + "total_audited": 6, + "fresh": 6, + "aging": 0, + "stale": 0, + "critical": 0 + } +} diff --git a/docs/backlog/items.md b/docs/backlog/items.md new file mode 100644 index 0000000..7428268 --- /dev/null +++ b/docs/backlog/items.md @@ -0,0 +1,29 @@ +# Backlog + +## Open + + + +## Completed + + diff --git a/docs/backlog/questions.md b/docs/backlog/questions.md new file mode 100644 index 0000000..36a2536 --- /dev/null +++ b/docs/backlog/questions.md @@ -0,0 +1,27 @@ +# Open Questions + +Questions that need answers before work can proceed. Move answered questions to the bottom. + +## Open + + + +## Answered + + diff --git a/docs/decisions/2026-05-15-migrate-to-tauri.md b/docs/decisions/2026-05-15-migrate-to-tauri.md new file mode 100644 index 0000000..3f49fb1 --- /dev/null +++ b/docs/decisions/2026-05-15-migrate-to-tauri.md @@ -0,0 +1,32 @@ +# Migrate from Flask to Tauri Desktop App + +**Date**: 2026-05-15 +**Status**: accepted + +## Context + +DocuGit started as a Flask+Vite two-process web app. The user runs a Python backend and a Vite dev server, then opens a browser tab. This worked for prototyping but had friction: two processes to manage, no native OS integration, no access to PTY for an embedded terminal, and the app felt like a web page rather than a tool. + +## Decision + +Replace Flask+Vite with a single Tauri 2 macOS desktop app. The React frontend runs in a native WebView, and a Rust backend handles file I/O, git operations, and config via IPC commands. + +## Reasoning + +- **Single binary**: One process to launch, no port management, no browser tab +- **Native feel**: macOS window chrome, document-edited indicator, native file dialogs +- **PTY support**: Rust's `portable-pty` enables an embedded terminal — not feasible from Flask +- **Performance**: Rust file I/O and git operations (via subprocess) are fast with no HTTP overhead +- **Distribution**: Tauri builds `.dmg` and `.app` bundles for macOS +- **Alternatives considered**: + - **Electron**: Heavier runtime (~100MB+ vs ~5MB for Tauri), less native feel + - **Keep Flask**: No path to PTY, terminal, or native integration + - **Wails (Go)**: Smaller ecosystem, less mature than Tauri 2 + +## Consequences + +- **macOS only for now** — Tauri supports Windows/Linux but we haven't tested or configured those targets +- **Rust required** — contributors need the Rust toolchain, which is a higher bar than Python +- **No web access** — the app is local-only (was also true with Flask, but now it's structural) +- **GitPython replaced** — git operations are now via CLI subprocess, which is actually more transparent and debuggable +- **Frontend unchanged** — React + TipTap + Tailwind carried over with only import path changes (`fetch` → `invoke`) diff --git a/docs/decisions/2026-05-20-file-watching-with-notify.md b/docs/decisions/2026-05-20-file-watching-with-notify.md new file mode 100644 index 0000000..bb98dd1 --- /dev/null +++ b/docs/decisions/2026-05-20-file-watching-with-notify.md @@ -0,0 +1,31 @@ +# File Watching with Rust notify Crate + +**Date**: 2026-05-20 +**Status**: accepted + +## Context + +DocuGit had no mechanism to detect external file changes. If a user edited a `.md` file outside the app (e.g., via an LLM tool, terminal, or another editor), the UI wouldn't reflect the change until the user manually re-opened the file. For a tool designed around LLM-assisted doc workflows, this was a significant gap. + +## Decision + +Use the Rust `notify` crate (v6.1) to watch the repo directory recursively for `.md` file changes. The watcher emits Tauri events (`fs:file-changed`, `fs:tree-changed`) that the React frontend listens for and reacts to. + +## Reasoning + +- **Event-driven**: macOS FSEvents via `notify` is efficient — no polling, no wasted CPU +- **Filtered and debounced**: Only `.md` files trigger events, with 1-second per-path debouncing to avoid rapid-fire updates during saves +- **Two event types**: `fs:file-changed` (content modification) refreshes the active editor; `fs:tree-changed` (create/delete) refreshes the sidebar file tree +- **Self-save handling**: When the user saves via the app, the watcher fires but the frontend compares content and skips redundant updates — no flicker or cursor loss +- **Alternatives considered**: + - **`tauri-plugin-fs-watch`**: Extra plugin dependency, less control over filtering and debouncing + - **`setInterval` polling**: Simple but wasteful, introduces latency, harder to get right + - **No watching**: Viable but poor UX for the LLM-assisted workflow where files change externally + +## Consequences + +- **Immediate refresh**: External edits appear in the editor within ~1 second +- **Dirty files protected**: If the user has unsaved changes, external updates are ignored (no data loss) +- **Editor remounts on external change**: Uses a `contentRevision` key bump to force TipTap to re-initialize — cursor position is lost, but this is acceptable for external changes +- **Watcher lifecycle tied to repo**: Starts when a repo is selected, stops on cleanup or repo switch +- **Resource cost**: One FSEvents stream per repo — negligible on macOS diff --git a/docs/decisions/_template.md b/docs/decisions/_template.md new file mode 100644 index 0000000..d5adae4 --- /dev/null +++ b/docs/decisions/_template.md @@ -0,0 +1,20 @@ +# [Decision Title] + +**Date**: YYYY-MM-DD +**Status**: proposed | accepted | superseded by [link] + +## Context + +What prompted this decision? What problem were we solving? + +## Decision + +What did we decide? + +## Reasoning + +Why this over alternatives? What trade-offs did we consider? + +## Consequences + +What follows from this decision? What doors does it open or close? diff --git a/docs/design-system-prompt.md b/docs/design-system-prompt.md new file mode 100644 index 0000000..474b1af --- /dev/null +++ b/docs/design-system-prompt.md @@ -0,0 +1,148 @@ +# DocuGit Design System Brief + +## What is DocuGit? + +DocuGit is a macOS desktop application for editing markdown files with built-in git integration. Think of it as a lightweight, Word-like writing environment that lives on top of a git repository. + +### The problem it solves + +Developers and technical writers increasingly use LLMs (Claude, ChatGPT, etc.) to generate markdown documentation — project specs, READMEs, design docs, technical guides. These documents end up in git repos, but editing them is awkward: + +- **IDEs** (VS Code, JetBrains) treat markdown as code. You're staring at raw syntax, managing split previews, and fighting an interface built for programming. +- **Dedicated markdown editors** (Obsidian, Typora) don't understand git. You lose staging, diffing, branching, and commit workflows. +- **Google Docs / Notion** aren't local-first, don't output clean markdown, and don't integrate with git at all. + +DocuGit bridges this gap: a clean WYSIWYG editor where markdown looks like a finished document, with git operations always one click away. + +### Target audience + +- **Developers** who write and maintain documentation alongside code +- **Technical writers** working in git-based repos +- **LLM power users** who generate markdown docs as part of AI-assisted workflows and need a comfortable place to review, edit, and version them +- **Solo creators and small teams** who want a simple, local-first writing tool without SaaS overhead + +The common thread: people who work with markdown in git repos and want a writing-first experience, not a coding-first one. + +## Current state of the app + +DocuGit is functional but visually undesigned. The UI was built pragmatically with Tailwind utility classes — it works, but there's no intentional design system, no visual identity, and several inconsistencies. It's ready for a real design pass. + +### Tech stack + +- **Desktop shell:** Tauri 2 (Rust backend, WebView frontend) — macOS only for now +- **Frontend:** React 19, Tailwind CSS 4, TipTap v3 (WYSIWYG editor) +- **Embedded terminal:** xterm.js with PTY support +- **File watching:** Rust `notify` crate — auto-refreshes editor when files change on disk + +### Application structure + +The app is a single-window, multi-panel layout: + +``` ++-------+----------------------------+---------+ +| Header (logo, panel toggles, repo path) | ++-------+----------------------------+---------+ +| | | | +| Side | Editor | Git | +| bar | (TipTap WYSIWYG) | Panel | +| 256px | | 320px | +| | | | +| | | | ++-------+----------------------------+---------+ +| Terminal (xterm.js, collapsible) | ++----------------------------------------------+ +``` + +**Header bar (~40px):** "DocuGit" text logo, three toggle buttons (Files / Git / Terminal), and the current repo path right-aligned. + +**Sidebar (256px, collapsible):** +- Repo picker with four modes (open doc, choose folder, scan for repos, detect from path) +- Hierarchical file tree showing only `.md` files +- "New File" creation form +- Active file highlighted in blue + +**Editor (flexible center):** +- Formatting toolbar: headings (H1-H3), bold/italic/strike/code, lists, blockquote, code block, horizontal rule, undo/redo, save button +- TipTap WYSIWYG area with prose typography (styled via @tailwindcss/typography) +- File path display with dirty indicator (orange asterisk) +- Empty state when no file is open + +**Git panel (320px, collapsible):** +- Branch selector with create-new-branch form +- Changed/untracked files list with checkboxes for selective staging +- Staged files list with unstage option +- Commit form with message textarea +- Push button with unpushed commit list + +**Terminal panel (~288px, collapsible):** +- Dark-themed xterm.js terminal with full PTY support +- Header bar with close button + +### Current visual style + +The app uses an ad-hoc gray palette with minimal color accents: + +- **Backgrounds:** White (editor), Gray-50 (sidebar, git panel, header), #1e1e1e (terminal) +- **Interactive elements:** Gray-700 for primary buttons, Blue-600 for save/links, Green-600 for commit +- **Text:** Gray-400 through Gray-700 for varying emphasis +- **Borders:** Gray-200 (light dividers), Gray-300 (inputs) +- **Typography:** System font, mostly xs (0.75rem) for UI chrome, prose sizing in editor +- **Active states:** Inconsistent — sometimes blue highlight (file tree), sometimes gray-700 with white text (toolbar, toggles) + +### Known design issues + +- No visual identity — "DocuGit" is just text, no logo or brand treatment +- No dark mode (terminal is dark but everything else is light-only) +- Active/selected states use two different color systems (blue vs. gray) with no clear logic +- UI text is uniformly tiny (xs/0.75rem) — functional but cramped +- No spacing rhythm or consistent sizing scale +- Toolbar buttons are text-only with no icons +- No visual hierarchy between panels — everything is the same gray +- No transitions or micro-animations +- File tree has no file type icons +- Git status labels are plain text with no color coding +- No empty states with personality (just gray text) +- Accessibility gaps: missing ARIA labels, no focus management for dropdowns + +## What I need + +Design a **design system** for DocuGit — a cohesive set of foundations and components that can be applied across the entire app. This should feel like a polished, native-quality macOS writing tool, not a web app crammed into a window. + +### Design system deliverables + +**1. Foundations** + +- **Color palette:** Light mode and dark mode tokens. The app should feel calm and paper-like in the editor area but capable and dense in the chrome (sidebar, git panel). Consider how the terminal's dark theme coexists with both modes. +- **Typography scale:** A clear type ramp for UI chrome (labels, buttons, file names) vs. editor content (prose). Include font recommendations — system fonts are fine, but specify the stack and any weight/size pairings. +- **Spacing and sizing:** A consistent spacing scale and component sizing system (button heights, input heights, panel padding, icon sizes). +- **Elevation and layering:** How panels, dropdowns, modals, and tooltips relate spatially. Shadow and border treatments. +- **Iconography direction:** Style recommendation for icons (outlined, solid, stroke weight). Don't need individual icons designed, just the system. +- **Motion principles:** Guidelines for transitions (panel show/hide, dropdown open, state changes). Keep it subtle and fast. + +**2. Core components** + +Design these key components within the system: + +- **Buttons:** Primary, secondary, ghost, destructive. Active/disabled/loading states. +- **Toggle buttons:** For panel visibility (Files/Git/Terminal). On/off states that are immediately readable. +- **Text inputs and textareas:** For file names, commit messages, branch names. Focus, error, disabled states. +- **File tree item:** File and folder variants. Selected, hover, and depth-indentation treatment. Consider file status indicators (modified, staged, new) that align with git panel. +- **Toolbar button:** For the editor formatting bar. Active (format applied), hover, disabled. Consider icon+text vs. icon-only. +- **Dropdown / select:** For branch picker and any future selects. Open/closed states, selected item treatment. +- **Status badges:** For git file statuses (modified, added, deleted, untracked, staged). Small, color-coded, readable at a glance. +- **Panel chrome:** Header/title treatment for sidebar, git panel, terminal. Collapse/expand affordance. + +**3. Layout system** + +- Panel structure and resizing behavior +- How panels collapse and expand (animation, toggle affordance) +- Responsive behavior as the window resizes (minimum widths, what collapses first) +- Editor area treatment — should it feel like a page (centered, max-width) or fill available space? + +### Design direction + +- **Native feel:** Should feel at home on macOS — respect platform conventions for window chrome, control sizing, and interaction patterns +- **Writing-first:** The editor is the hero. Everything else should support the writing experience without competing for attention +- **Professional but approachable:** This isn't a toy or a hacker tool. It should feel trustworthy for real work while still being pleasant to use +- **Information density:** The sidebar and git panel should be compact and scannable. Don't waste space, but don't cram things either +- **Calm:** Muted palette with purposeful color. Reserve bright colors for meaningful signals (unsaved changes, git status, errors) diff --git a/docs/getting-started.md b/docs/getting-started.md index 7104442..b919c93 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -57,4 +57,4 @@ New branches are created from your current HEAD and checked out automatically. - **Cmd+B** bolds selected text - **Cmd+I** italicizes selected text - Click **Browse for Document** in the sidebar to open any markdown file on your machine -- The Git panel toggles with the **Git** button in the top-right corner +- The Git panel toggles with the **Git** button in the header bar diff --git a/docs/history.md b/docs/history.md new file mode 100644 index 0000000..e020d97 --- /dev/null +++ b/docs/history.md @@ -0,0 +1,41 @@ +# Project History + +Auto-generated by brain-implant from git history on 2026-05-20. +Edit freely — this is your document now. Run `/update-brain` to append recent activity. + +--- + +## Timeline + +### 2026-05 — May (current) +_10+ commits, 2 contributors_ + +- Add one-line start script, clean up README and config +- Adding toggle +- Support non-git dirs, add collapsible sidebar and git panel +- Migrate from Flask web app to Tauri macOS desktop app +- Add embedded terminal with PTY support +- Add file watching via `notify` crate (auto-refresh editor on external changes) +- Add TipTap table extension support +- Brain-implant initialized for knowledge management + +--- + +## Architecture Evolution + +Key structural changes detected from git history: + +- **2026-05-01**: Project initialized (React + Flask + TipTap) +- **2026-05-15**: Migrate from Flask web app to Tauri macOS desktop app +- **2026-05-15**: Add embedded terminal with PTY support (portable-pty + xterm.js) +- **2026-05-20**: Add filesystem watcher (Rust `notify` crate) for live document refresh + +--- + +## Key Decisions + +Decision signals extracted from commit messages: + +- **2026-05-15**: Migrate from Flask web app to Tauri macOS desktop app — single binary, native feel, PTY support +- **2026-05-20**: Use `notify` crate for file watching over polling — event-driven, debounced, filtered to .md files + diff --git a/docs/reports/audit-2026-05-21.md b/docs/reports/audit-2026-05-21.md new file mode 100644 index 0000000..a2e5a46 --- /dev/null +++ b/docs/reports/audit-2026-05-21.md @@ -0,0 +1,71 @@ +# Documentation Health Audit - 2026-05-21 + +## Summary +- **Total docs audited**: 6 +- **Fresh** (< 0.5): 6 +- **Aging** (0.5-0.99): 0 +- **Stale** (1.0-1.99): 0 +- **Critical** (2.0+): 0 + +## Overall Grade: A + +> 100% fresh, 0 critical + +--- + +## Fresh Docs (score < 0.5) + +| Doc | Last Updated | Score | Notes | +|-----|-------------|-------|-------| +| `docs/design-system-prompt.md` | May 20 | 0.0 | References code; updated same day | +| `docs/history.md` | May 20 | 0.0 | References code; updated same day | +| `docs/getting-started.md` | May 20 | 0.01 | Standalone; 1 day old | +| `docs/welcome.md` | May 20 | 0.01 | Standalone; 1 day old | +| `docs/backlog/items.md` | May 20 | 0.01 | Empty scaffold | +| `CLAUDE.md` | May 20 | 0.0 | Config; current | + +## Skipped +- `docs/sessions/session-01.md` — session log +- `docs/backlog/questions.md` — empty template +- `docs/decisions/_template.md` — template +- `docs/sprints/_sprint-template/TEMPLATE.md` — template + +--- + +## Coverage Gaps + +| Directory | Files | Docs Referencing | +|-----------|-------|-----------------| +| `src/components/` | 16 | 1 (`design-system-prompt.md`) | +| `src-tauri/src/` | 7 | 1 (`design-system-prompt.md`) | + +References are high-level (architecture overview for designers), not developer-facing API docs. + +## Decision Health +- **Total decision records**: 0 at time of audit (2 created after audit) +- **Recommendation**: Record Tauri migration and file watcher decisions + +## History Coverage +- `history.md` covers through: **2026-05** (current month) +- Status: Current + +--- + +## Recommended Actions + +1. Record architectural decisions in `docs/decisions/` — done post-audit +2. Consider developer-facing architecture doc for `src-tauri/src/` command API +3. No stale or critical docs — documentation is healthy + +--- + +## Comparison to Previous Audit + +| Metric | 2026-05-20 | 2026-05-21 | Change | +|--------|-----------|-----------|--------| +| Grade | B | A | +1 | +| Fresh | 3 | 6 | +3 | +| Stale | 2 | 0 | -2 | +| Critical | 0 | 0 | — | + +Improvement driven by `/update-brain` run on 2026-05-20 which fixed stale docs. diff --git a/docs/reports/daily/.gitkeep b/docs/reports/daily/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/docs/reports/weekly/.gitkeep b/docs/reports/weekly/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/docs/sessions/.gitkeep b/docs/sessions/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/docs/sessions/session-01.md b/docs/sessions/session-01.md new file mode 100644 index 0000000..8e63190 --- /dev/null +++ b/docs/sessions/session-01.md @@ -0,0 +1,146 @@ +# Development Log - Session 01 + +**Date**: 2026-05-20 +**Duration**: ~1.5 hours + +--- + +## Current State & How to Resume + +### System State (as of session end) +- Branch: `feature/terminal` — pushed to origin +- PR #2 open against `main`: "Migrate to Tauri desktop app with terminal, file watcher, and table support" +- App builds successfully (`cargo tauri dev`) +- All features functional: file watcher, table support, terminal, git panel + +### To Verify State +```bash +cd /Users/xray/software/docugit +git status +cargo check --manifest-path src-tauri/Cargo.toml +npx vite build +``` + +### To Resume Development +1. Run `cargo tauri dev` to launch the app +2. Check PR #2 for any review feedback +3. Next focus area: design system implementation (prompt is ready at `docs/design-system-prompt.md`) + +### Checklist Status +| Feature | Status | Notes | +|---------|--------|-------| +| File watcher (notify crate) | Done | Watches .md files, debounced, emits Tauri events | +| Auto-refresh editor | Done | Re-reads file on external change if not dirty | +| Auto-refresh file tree | Done | Tree updates on file create/delete | +| Table support (TipTap) | Done | Named exports fix applied after initial blank screen | +| Design system prompt | Done | Comprehensive brief at docs/design-system-prompt.md | +| Brain-implant init | Done | Skills installed, first audit + update-brain run complete | +| PR created | Done | PR #2 on GitHub | + +--- + +## Executive Summary + +This session added three features to DocuGit: **file watching** (Rust `notify` crate watches the repo and auto-refreshes the editor/tree via Tauri events), **markdown table support** (TipTap table extensions), and a **design system prompt** for a future UI redesign. + +We also initialized brain-implant for knowledge management and ran the first documentation audit (grade B — 3 fresh, 2 stale, 0 critical). The stale docs turned out to be functionally accurate despite the Flask-to-Tauri migration; only minor corrections were needed. + +A bug was introduced and fixed mid-session: the TipTap table extensions use named exports (`{ Table }`) not default exports (`Table`), which caused a blank white screen. Fixed by switching import syntax. + +--- + +## Detailed Progress + +### File Watching +- Created `src-tauri/src/watcher.rs` — Rust module using `notify 6.1` crate +- `WatcherState` holds an optional `RecommendedWatcher` in a `Mutex` +- `watch_repo` command starts recursive watching with debouncing (1s per path) +- Filters to `.md` files only, emits `fs:file-changed` (relative path) and `fs:tree-changed` (create/delete only) +- Frontend uses refs to access current state inside event listeners (React closure stale-state pattern) +- `contentRevision` counter bumped on external changes to force TipTap editor remount via key prop +- Self-saves naturally ignored: content comparison prevents redundant updates + +#### Technical Decisions +**Notify crate over Tauri fs-watch plugin** +- Context: Needed file watching for auto-refresh +- Options: `tauri-plugin-fs-watch`, `notify` crate directly, polling with `setInterval` +- Decision: `notify` crate directly +- Rationale: Lighter dependency, full control over debouncing and event filtering, no plugin compatibility concerns + +**Editor remount via key prop vs setContent** +- Context: TipTap ignores content prop changes after initialization +- Options: `editor.commands.setContent()` (preserves instance), key change (remounts) +- Decision: Key change with `contentRevision` counter +- Rationale: Simpler, and cursor position loss is acceptable for external changes + +### Table Support +- Installed `@tiptap/extension-table`, `table-row`, `table-header`, `table-cell` at version 3.22.5 (matching existing TipTap) +- Added table/cell CSS styles to `src/index.css` +- Bug: default imports caused blank screen — TipTap table extensions export named, not default + +### Design System Prompt +- Created comprehensive brief at `docs/design-system-prompt.md` +- Covers: problem statement, audience, full UI inventory, current visual style, known issues, specific deliverables (foundations + 14 components + layout system), design direction + +### Documentation +- Ran `/audit-docs` — grade B, identified getting-started.md and welcome.md as stale +- Ran `/update-brain` — fixed history.md duplicates, added table example to welcome.md, minor getting-started.md correction +- No Claude memories promoted (project overview already in docs, user prefs stay in memory) + +--- + +## Files Created/Modified + +### New Files +- `src-tauri/src/watcher.rs` — File watcher module (notify crate, Tauri event emission) +- `docs/design-system-prompt.md` — Design system brief for LLM design tool +- `docs/history.md` — Project history (auto-generated, then cleaned up) +- `docs/.brain-state.json` — Brain-implant state tracking +- `CLAUDE.md` — Brain-implant configuration +- `brain.yaml` — Brain-implant config +- `docs/backlog/`, `docs/decisions/`, `docs/reports/`, `docs/sessions/`, `docs/sprints/` — Brain-implant scaffolding + +### Modified Files +- `src-tauri/Cargo.toml` — Added `notify = "6.1"` +- `src-tauri/src/lib.rs` — Registered watcher module, state, and commands +- `src/App.jsx` — Added watcher lifecycle, event listeners, contentRevision state, refs +- `src/components/Editor/Editor.jsx` — Added table extensions (named imports) +- `src/index.css` — Added table styles +- `src/tauri-api.js` — Added `watchRepo`/`unwatchRepo` functions +- `package.json` — Added TipTap table extension dependencies +- `docs/getting-started.md` — Fixed Git toggle button location +- `docs/welcome.md` — Added table example section + +--- + +## Known Issues & Technical Debt + +### Blank screen on import errors +- **Status**: Resolved +- **Description**: Default imports of TipTap extensions that only export named caused the entire app to white-screen with no visible error +- **Lesson**: Always check export style when adding new TipTap extensions + +### No dark mode +- **Status**: Deferred +- **Description**: Terminal is dark but all other panels are light-only +- **Impact**: Design system work will address this + +### File watcher debounce edge cases +- **Status**: Acceptable +- **Description**: If two different users edit the same file within the 1s debounce window, the second change may be missed +- **Impact**: Negligible for single-user desktop app + +--- + +## Next Steps + +### Immediate (Next Session) +1. Review PR #2 feedback and merge to main +2. Use `docs/design-system-prompt.md` with a design tool to generate the design system +3. Begin implementing the design system (colors, typography, component styles) + +### Upcoming +- Dark mode support +- Editor toolbar icons (currently text-only) +- Accessibility improvements (ARIA labels, focus management) +- Consider resizable panels (drag-to-resize sidebar/git panel) diff --git a/docs/sprints/.current-sprint b/docs/sprints/.current-sprint new file mode 100644 index 0000000..e69de29 diff --git a/docs/sprints/_sprint-template/TEMPLATE.md b/docs/sprints/_sprint-template/TEMPLATE.md new file mode 100644 index 0000000..9928bff --- /dev/null +++ b/docs/sprints/_sprint-template/TEMPLATE.md @@ -0,0 +1,42 @@ +# Sprint: [Name] + +## Executive Summary + +What this sprint delivers and why. 2-3 sentences. + +## Context + +What preceded this sprint. What problems or opportunities prompted it. + +### What the Previous Sprint Built + +- Item 1 +- Item 2 + +### What the Previous Sprint Revealed + +- Problem 1 +- Problem 2 + +## Goals + +### Primary + +- Goal 1 +- Goal 2 + +### Success Metrics + +| Metric | Target | Rationale | +|--------|--------|-----------| +| ... | ... | ... | + +## Design Principles + +- Principle 1 +- Principle 2 + +## Out of Scope + +- Item 1 +- Item 2 diff --git a/docs/welcome.md b/docs/welcome.md index 0ba6f29..349b9a9 100644 --- a/docs/welcome.md +++ b/docs/welcome.md @@ -32,6 +32,16 @@ def hello(): > DocuGit gives you a Word-like editing experience for markdown files that live in git repos. +## Tables Work Too + +| Feature | Shortcut | +|---------|----------| +| Save | Cmd+S | +| Bold | Cmd+B | +| Italic | Cmd+I | + +Tables render as formatted grids right in the editor. Try adding a row. + ## What's Next? Once you've made some edits and saved, head over to the **Git panel** on the right side of the screen to stage, commit, and push your changes. See `getting-started.md` for a walkthrough. diff --git a/package-lock.json b/package-lock.json index 5af7b94..f284e65 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,6 +12,10 @@ "@tailwindcss/vite": "^4.1.18", "@tauri-apps/api": "^2.11.0", "@tauri-apps/plugin-dialog": "^2.7.1", + "@tiptap/extension-table": "^3.22.5", + "@tiptap/extension-table-cell": "^3.22.5", + "@tiptap/extension-table-header": "^3.22.5", + "@tiptap/extension-table-row": "^3.22.5", "@tiptap/markdown": "^3.22.5", "@tiptap/pm": "^3.22.5", "@tiptap/react": "^3.22.5", @@ -66,6 +70,7 @@ "integrity": "sha512-H3mcG6ZDLTlYfaSNi0iOKkigqMFvkTKlGUYlD8GW7nNOYRrevuA46iTypPyv+06V3fEmvvazfntkBU34L0azAw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "^7.28.6", "@babel/generator": "^7.28.6", @@ -900,17 +905,6 @@ "@floating-ui/utils": "^0.2.11" } }, - "node_modules/@floating-ui/dom": { - "version": "1.7.6", - "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.6.tgz", - "integrity": "sha512-9gZSAI5XM36880PPMm//9dfiEngYoC6Am2izES1FF406YFsjvyBMmeJ2g4SAju3xWwtuynNRFL2s9hgxpLI5SQ==", - "license": "MIT", - "optional": true, - "dependencies": { - "@floating-ui/core": "^1.7.5", - "@floating-ui/utils": "^0.2.11" - } - }, "node_modules/@floating-ui/utils": { "version": "0.2.11", "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.11.tgz", @@ -1857,6 +1851,7 @@ "resolved": "https://registry.npmjs.org/@tiptap/core/-/core-3.22.5.tgz", "integrity": "sha512-L1lhWz6ujGny8LduTJ7MBWYhzigwOvfUJUrJ7IzOJSuy3+OAzisdGDD1GV7LEO/hU0Hr2Mkm1wajRIHExvS9HQ==", "license": "MIT", + "peer": true, "funding": { "type": "github", "url": "https://github.com/sponsors/ueberdosis" @@ -2079,6 +2074,7 @@ "resolved": "https://registry.npmjs.org/@tiptap/extension-list/-/extension-list-3.22.5.tgz", "integrity": "sha512-cVO3ZHCgxAWZ4zrFSs81FO2nyCk1wb2EHkpLpW98FzbJLkN9rDkazhW99P3HRWy/CvUldOT+8ecI1YrQtBojMg==", "license": "MIT", + "peer": true, "funding": { "type": "github", "url": "https://github.com/sponsors/ueberdosis" @@ -2153,6 +2149,60 @@ "@tiptap/core": "3.22.5" } }, + "node_modules/@tiptap/extension-table": { + "version": "3.22.5", + "resolved": "https://registry.npmjs.org/@tiptap/extension-table/-/extension-table-3.22.5.tgz", + "integrity": "sha512-GMBM07bCwzHx1NK08zXRr2mNTDnP78Hd0VxFsRBIDFddDMZ2qG5jhwKHXN5cHMTrdWokWFUjvnEeJeV3guHoGg==", + "license": "MIT", + "peer": true, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "3.22.5", + "@tiptap/pm": "3.22.5" + } + }, + "node_modules/@tiptap/extension-table-cell": { + "version": "3.22.5", + "resolved": "https://registry.npmjs.org/@tiptap/extension-table-cell/-/extension-table-cell-3.22.5.tgz", + "integrity": "sha512-Wn4asCgNLfOPH5EOpiMjzOJXTZvv+TTqUT+gzm2fV69ZkleCGNO0BZwuR/TCIDLGIArbvHzyYy2/lJAfG4UCtg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/extension-table": "3.22.5" + } + }, + "node_modules/@tiptap/extension-table-header": { + "version": "3.22.5", + "resolved": "https://registry.npmjs.org/@tiptap/extension-table-header/-/extension-table-header-3.22.5.tgz", + "integrity": "sha512-aJmbgbO6QbSj0Rw3X4ogGPyd+8FwP6RgG71Dpa3NovzVkqJc3ZUq0wC3XH48U9Hd89F8f4AggFgHjU6/kQAgQQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/extension-table": "3.22.5" + } + }, + "node_modules/@tiptap/extension-table-row": { + "version": "3.22.5", + "resolved": "https://registry.npmjs.org/@tiptap/extension-table-row/-/extension-table-row-3.22.5.tgz", + "integrity": "sha512-9A2BdX+R+P71f192Fo74OttMHj1WoFVO0ezaCzFbT8uNVG3nCJ7B5/1UkTlzqDdGOuWh1VpR63pFZP9LFsUv6A==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/extension-table": "3.22.5" + } + }, "node_modules/@tiptap/extension-text": { "version": "3.22.5", "resolved": "https://registry.npmjs.org/@tiptap/extension-text/-/extension-text-3.22.5.tgz", @@ -2184,6 +2234,7 @@ "resolved": "https://registry.npmjs.org/@tiptap/extensions/-/extensions-3.22.5.tgz", "integrity": "sha512-Ifg4MzKCj3uRqe3ieTwYnomu2y4p7EXr2avVSKZYfh12i2dyWe2Gkn1KuZDREANVE+gHqFlQjJRYzhJFwzSCrg==", "license": "MIT", + "peer": true, "funding": { "type": "github", "url": "https://github.com/sponsors/ueberdosis" @@ -2215,6 +2266,7 @@ "resolved": "https://registry.npmjs.org/@tiptap/pm/-/pm-3.22.5.tgz", "integrity": "sha512-Cr9Mv4igxvI2tKMiahw48sZxva3PfDzypErH8IB82N+9qa9n9ygVMt0BOaDg53hLKxEEVeYr2S/wCcJIVFgBTw==", "license": "MIT", + "peer": true, "dependencies": { "prosemirror-changeset": "^2.3.0", "prosemirror-commands": "^1.6.2", @@ -2360,6 +2412,7 @@ "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.8.tgz", "integrity": "sha512-3MbSL37jEchWZz2p2mjntRZtPt837ij10ApxKfgmXCTuHWagYg7iA5bqPw6C8BMPfwidlvfPI/fxOc42HLhcyg==", "license": "MIT", + "peer": true, "dependencies": { "csstype": "^3.2.2" } @@ -2369,6 +2422,7 @@ "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz", "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", "license": "MIT", + "peer": true, "peerDependencies": { "@types/react": "^19.2.0" } @@ -2421,6 +2475,7 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -2526,6 +2581,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -2779,6 +2835,7 @@ "integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -3766,6 +3823,7 @@ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -3968,6 +4026,7 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.2.3.tgz", "integrity": "sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA==", "license": "MIT", + "peer": true, "engines": { "node": ">=0.10.0" } @@ -3977,6 +4036,7 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.3.tgz", "integrity": "sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg==", "license": "MIT", + "peer": true, "dependencies": { "scheduler": "^0.27.0" }, @@ -4132,7 +4192,8 @@ "version": "4.1.18", "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.18.tgz", "integrity": "sha512-4+Z+0yiYyEtUVCScyfHCxOYP06L5Ne+JiHhY2IjR2KWMIWhJOYZKLSGZaP5HkZ8+bY0cxfzwDE5uOmzFXyIwxw==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/tapable": { "version": "2.3.0", @@ -4237,6 +4298,7 @@ "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz", "integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==", "license": "MIT", + "peer": true, "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", @@ -4364,6 +4426,7 @@ "integrity": "sha512-k7Nwx6vuWx1IJ9Bjuf4Zt1PEllcwe7cls3VNzm4CQ1/hgtFUK2bRNG3rvnpPUhFjmqJKAKtjV576KnUkHocg/g==", "dev": true, "license": "MIT", + "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } diff --git a/package.json b/package.json index 335544c..6b57564 100644 --- a/package.json +++ b/package.json @@ -15,6 +15,10 @@ "@tailwindcss/vite": "^4.1.18", "@tauri-apps/api": "^2.11.0", "@tauri-apps/plugin-dialog": "^2.7.1", + "@tiptap/extension-table": "^3.22.5", + "@tiptap/extension-table-cell": "^3.22.5", + "@tiptap/extension-table-header": "^3.22.5", + "@tiptap/extension-table-row": "^3.22.5", "@tiptap/markdown": "^3.22.5", "@tiptap/pm": "^3.22.5", "@tiptap/react": "^3.22.5", diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index c5203b1..dc50c72 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -732,6 +732,7 @@ version = "0.1.0" dependencies = [ "dirs", "log", + "notify", "portable-pty", "serde", "serde_json", @@ -905,6 +906,16 @@ dependencies = [ "winapi", ] +[[package]] +name = "filetime" +version = "0.2.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c287a33c7f0a620c38e641e7f60827713987b3c0f26e8ddc9462cc69cf75759" +dependencies = [ + "cfg-if", + "libc", +] + [[package]] name = "find-msvc-tools" version = "0.1.9" @@ -975,6 +986,15 @@ dependencies = [ "percent-encoding", ] +[[package]] +name = "fsevent-sys" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76ee7a02da4d231650c7cea31349b889be2f45ddb3ef3032d2ec8185f6313fd2" +dependencies = [ + "libc", +] + [[package]] name = "funty" version = "2.0.0" @@ -1660,6 +1680,26 @@ dependencies = [ "cfb", ] +[[package]] +name = "inotify" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8069d3ec154eb856955c1c0fbffefbf5f3c40a104ec912d4797314c1801abff" +dependencies = [ + "bitflags 1.3.2", + "inotify-sys", + "libc", +] + +[[package]] +name = "inotify-sys" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e05c02b5e89bff3b946cedeca278abc628fe811e604f027c45a8aa3cf793d0eb" +dependencies = [ + "libc", +] + [[package]] name = "ioctl-rs" version = "0.1.6" @@ -1793,6 +1833,26 @@ dependencies = [ "unicode-segmentation", ] +[[package]] +name = "kqueue" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eac30106d7dce88daf4a3fcb4879ea939476d5074a9b7ddd0fb97fa4bed5596a" +dependencies = [ + "kqueue-sys", + "libc", +] + +[[package]] +name = "kqueue-sys" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07293a4e297ac234359b510362495713f75ea345d5307140414f20c69ffeb087" +dependencies = [ + "bitflags 2.11.1", + "libc", +] + [[package]] name = "lazy_static" version = "1.5.0" @@ -1938,6 +1998,18 @@ dependencies = [ "simd-adler32", ] +[[package]] +name = "mio" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4a650543ca06a924e8b371db273b2756685faae30f8487da1b56505a8f78b0c" +dependencies = [ + "libc", + "log", + "wasi", + "windows-sys 0.48.0", +] + [[package]] name = "mio" version = "1.2.0" @@ -2014,6 +2086,25 @@ dependencies = [ "pin-utils", ] +[[package]] +name = "notify" +version = "6.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6205bd8bb1e454ad2e27422015fb5e4f2bcc7e08fa8f27058670d208324a4d2d" +dependencies = [ + "bitflags 2.11.1", + "crossbeam-channel", + "filetime", + "fsevent-sys", + "inotify", + "kqueue", + "libc", + "log", + "mio 0.8.11", + "walkdir", + "windows-sys 0.48.0", +] + [[package]] name = "num-conv" version = "0.2.1" @@ -3855,7 +3946,7 @@ checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe" dependencies = [ "bytes", "libc", - "mio", + "mio 1.2.0", "pin-project-lite", "socket2", "windows-sys 0.61.2", @@ -4691,6 +4782,15 @@ dependencies = [ "windows-targets 0.42.2", ] +[[package]] +name = "windows-sys" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" +dependencies = [ + "windows-targets 0.48.5", +] + [[package]] name = "windows-sys" version = "0.59.0" @@ -4733,6 +4833,21 @@ dependencies = [ "windows_x86_64_msvc 0.42.2", ] +[[package]] +name = "windows-targets" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" +dependencies = [ + "windows_aarch64_gnullvm 0.48.5", + "windows_aarch64_msvc 0.48.5", + "windows_i686_gnu 0.48.5", + "windows_i686_msvc 0.48.5", + "windows_x86_64_gnu 0.48.5", + "windows_x86_64_gnullvm 0.48.5", + "windows_x86_64_msvc 0.48.5", +] + [[package]] name = "windows-targets" version = "0.52.6" @@ -4790,6 +4905,12 @@ version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8" +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" + [[package]] name = "windows_aarch64_gnullvm" version = "0.52.6" @@ -4808,6 +4929,12 @@ version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43" +[[package]] +name = "windows_aarch64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" + [[package]] name = "windows_aarch64_msvc" version = "0.52.6" @@ -4826,6 +4953,12 @@ version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f" +[[package]] +name = "windows_i686_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" + [[package]] name = "windows_i686_gnu" version = "0.52.6" @@ -4856,6 +4989,12 @@ version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060" +[[package]] +name = "windows_i686_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" + [[package]] name = "windows_i686_msvc" version = "0.52.6" @@ -4874,6 +5013,12 @@ version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36" +[[package]] +name = "windows_x86_64_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" + [[package]] name = "windows_x86_64_gnu" version = "0.52.6" @@ -4892,6 +5037,12 @@ version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3" +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" + [[package]] name = "windows_x86_64_gnullvm" version = "0.52.6" @@ -4910,6 +5061,12 @@ version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0" +[[package]] +name = "windows_x86_64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" + [[package]] name = "windows_x86_64_msvc" version = "0.52.6" diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 6310f7e..77cdcf1 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -26,3 +26,4 @@ tauri-plugin-log = "2" dirs = "6" tauri-plugin-dialog = "2" portable-pty = "0.8" +notify = "6.1" diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 4e7d1d9..5473854 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -2,6 +2,7 @@ mod config; mod files; mod git; mod terminal; +mod watcher; #[tauri::command] fn get_config() -> Result { @@ -56,6 +57,7 @@ pub fn run() { tauri::Builder::default() .plugin(tauri_plugin_dialog::init()) .manage(terminal::PtyState::new()) + .manage(watcher::WatcherState::new()) .setup(|app| { if cfg!(debug_assertions) { app.handle().plugin( @@ -92,6 +94,8 @@ pub fn run() { terminal::pty_write, terminal::pty_resize, terminal::pty_kill, + watcher::watch_repo, + watcher::unwatch_repo, ]) .run(tauri::generate_context!()) .expect("error while running tauri application"); diff --git a/src-tauri/src/watcher.rs b/src-tauri/src/watcher.rs new file mode 100644 index 0000000..a5a9215 --- /dev/null +++ b/src-tauri/src/watcher.rs @@ -0,0 +1,102 @@ +use notify::{Config, RecommendedWatcher, RecursiveMode, Watcher}; +use std::collections::HashMap; +use std::path::{Path, PathBuf}; +use std::sync::Mutex; +use std::time::Instant; +use tauri::{AppHandle, Emitter, Manager}; + +pub struct WatcherState { + watcher: Mutex>, +} + +impl WatcherState { + pub fn new() -> Self { + Self { + watcher: Mutex::new(None), + } + } +} + +#[tauri::command] +pub fn watch_repo(repo_path: String, app: AppHandle) -> Result<(), String> { + let state = app.state::(); + let mut guard = state.watcher.lock().map_err(|e| e.to_string())?; + + // Drop existing watcher + *guard = None; + + let repo = PathBuf::from(&repo_path); + let debounce: Mutex> = Mutex::new(HashMap::new()); + let app_handle = app.clone(); + + let mut watcher = RecommendedWatcher::new( + move |res: Result| { + let Ok(event) = res else { return }; + + let is_structural = matches!( + event.kind, + notify::EventKind::Create(_) | notify::EventKind::Remove(_) + ); + let is_modify = matches!(event.kind, notify::EventKind::Modify(_)); + + if !is_structural && !is_modify { + return; + } + + let now = Instant::now(); + let mut seen = debounce.lock().unwrap(); + let mut tree_dirty = false; + + for path in &event.paths { + let is_md = path + .extension() + .map(|e| e.eq_ignore_ascii_case("md")) + .unwrap_or(false); + if !is_md { + continue; + } + + // Debounce: skip if seen < 1s ago + if let Some(last) = seen.get(path) { + if now.duration_since(*last).as_millis() < 1000 { + continue; + } + } + seen.insert(path.clone(), now); + + if let Ok(rel) = path.strip_prefix(&repo) { + let rel_str = rel.to_string_lossy().to_string(); + let _ = app_handle.emit("fs:file-changed", &rel_str); + } + + if is_structural { + tree_dirty = true; + } + } + + if tree_dirty { + let _ = app_handle.emit("fs:tree-changed", ()); + } + + // Cleanup old debounce entries + seen.retain(|_, t| now.duration_since(*t).as_secs() < 10); + }, + Config::default(), + ) + .map_err(|e| e.to_string())?; + + watcher + .watch(Path::new(&repo_path), RecursiveMode::Recursive) + .map_err(|e| e.to_string())?; + + *guard = Some(watcher); + Ok(()) +} + +#[tauri::command] +pub fn unwatch_repo(app: AppHandle) -> Result<(), String> { + let state = app.state::(); + let mut guard = state.watcher.lock().map_err(|e| e.to_string())?; + *guard = None; + Ok(()) +} diff --git a/src/App.jsx b/src/App.jsx index 3134c5f..7fbdcf0 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -1,4 +1,4 @@ -import { useState, useEffect, useCallback } from 'react' +import { useState, useEffect, useCallback, useRef } from 'react' import Layout from './components/Layout' import Sidebar from './components/Sidebar/Sidebar' import Editor from './components/Editor/Editor' @@ -36,6 +36,13 @@ function App() { const [terminalMounted, setTerminalMounted] = useState(false) const [loading, setLoading] = useState(false) const [error, setError] = useState(null) + const [contentRevision, setContentRevision] = useState(0) + const [externalContent, setExternalContent] = useState(null) + + // Refs for accessing current state inside event listeners + const activeFileRef = useRef(null) + const isDirtyRef = useRef(false) + const fileContentRef = useRef('') useEffect(() => { localStorage.setItem('docugit:showSidebar', showSidebar) @@ -92,6 +99,64 @@ function App() { }).catch(() => {}) }, [isDirty]) + // Keep refs in sync with state for use inside event listeners + useEffect(() => { activeFileRef.current = activeFilePath }, [activeFilePath]) + useEffect(() => { isDirtyRef.current = isDirty }, [isDirty]) + useEffect(() => { fileContentRef.current = fileContent }, [fileContent]) + + // Start file watcher when repo changes + useEffect(() => { + if (!repoPath) return + api.watchRepo(repoPath).catch(() => {}) + return () => { api.unwatchRepo().catch(() => {}) } + }, [repoPath]) + + // Listen for file system events from the watcher + useEffect(() => { + if (!repoPath) return + + let unlisten1, unlisten2 + let treeTimer = null + + const setup = async () => { + const { listen } = await import('@tauri-apps/api/event') + + unlisten1 = await listen('fs:file-changed', async (event) => { + const changedPath = event.payload + if (changedPath !== activeFileRef.current) return + try { + const data = await api.getFile(changedPath) + if (data.content === fileContentRef.current) return // no real change + if (!isDirtyRef.current) { + // Editor clean — auto-reload + setFileContent(data.content) + setContentRevision(prev => prev + 1) + } else { + // Editor dirty — conflict! Store the external version + setExternalContent(data.content) + } + } catch { + // File may have been deleted + } + }) + + unlisten2 = await listen('fs:tree-changed', () => { + // Debounce tree refreshes + if (treeTimer) clearTimeout(treeTimer) + treeTimer = setTimeout(() => refreshFiles(), 500) + }) + } + + setup() + + return () => { + unlisten1?.() + unlisten2?.() + if (treeTimer) clearTimeout(treeTimer) + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [repoPath]) + const handleSetRepo = async (path) => { try { setError(null) @@ -102,6 +167,7 @@ function App() { setActiveFilePath(null) setFileContent('') setIsDirty(false) + setExternalContent(null) } else { setError(data.error || 'Invalid repo path') } @@ -117,6 +183,7 @@ function App() { setActiveFilePath(null) setFileContent('') setIsDirty(false) + setExternalContent(null) setGitStatus(null) } @@ -131,6 +198,7 @@ function App() { setActiveFilePath(filepath) setFileContent(data.content) setIsDirty(false) + setExternalContent(null) localStorage.setItem('docugit:lastFile', filepath) } catch (err) { setError(err.message) @@ -152,6 +220,19 @@ function App() { } }, [activeFilePath, refreshGitStatus]) + const handleConflictKeepMine = () => { + setExternalContent(null) + } + + const handleConflictLoadTheirs = () => { + if (externalContent !== null) { + setFileContent(externalContent) + setContentRevision(prev => prev + 1) + setIsDirty(false) + setExternalContent(null) + } + } + const handleOpenDoc = async (filePath) => { if (isDirty && !window.confirm('You have unsaved changes. Discard them?')) { return @@ -170,6 +251,7 @@ function App() { setActiveFilePath(data.relative_path) setFileContent(data.content) setIsDirty(false) + setExternalContent(null) localStorage.setItem('docugit:lastFile', data.relative_path) } catch (err) { setError(err.message) @@ -219,12 +301,15 @@ function App() { editor={ activeFilePath ? ( ) : ( diff --git a/src/components/Editor/ConflictBanner.jsx b/src/components/Editor/ConflictBanner.jsx new file mode 100644 index 0000000..f574dbb --- /dev/null +++ b/src/components/Editor/ConflictBanner.jsx @@ -0,0 +1,23 @@ +export default function ConflictBanner({ onKeepMine, onLoadTheirs }) { + return ( +
+ + This file was modified externally while you have unsaved changes. + +
+ + +
+
+ ) +} diff --git a/src/components/Editor/Editor.jsx b/src/components/Editor/Editor.jsx index fb1600b..4d07150 100644 --- a/src/components/Editor/Editor.jsx +++ b/src/components/Editor/Editor.jsx @@ -1,14 +1,28 @@ -import { useEffect, useCallback } from 'react' +import { useState, useEffect, useCallback } from 'react' import { useEditor, EditorContent } from '@tiptap/react' import StarterKit from '@tiptap/starter-kit' import { Markdown } from '@tiptap/markdown' +import { Table } from '@tiptap/extension-table' +import { TableRow } from '@tiptap/extension-table-row' +import { TableHeader } from '@tiptap/extension-table-header' +import { TableCell } from '@tiptap/extension-table-cell' import Toolbar from './Toolbar' +import ConflictBanner from './ConflictBanner' +import { SearchExtension } from './SearchExtension' +import SearchBar from './SearchBar' + +export default function Editor({ content, isDirty, onDirtyChange, onSave, filePath, hasConflict, onConflictKeepMine, onConflictLoadTheirs }) { + const [showSearch, setShowSearch] = useState(false) -export default function Editor({ content, isDirty, onDirtyChange, onSave, filePath }) { const editor = useEditor({ extensions: [ StarterKit, + Table.configure({ resizable: true }), + TableRow, + TableHeader, + TableCell, Markdown, + SearchExtension, ], content: content, contentType: 'markdown', @@ -30,6 +44,10 @@ export default function Editor({ content, isDirty, onDirtyChange, onSave, filePa e.preventDefault() handleSave() } + if ((e.metaKey || e.ctrlKey) && e.key === 'f') { + e.preventDefault() + setShowSearch(true) + } } window.addEventListener('keydown', handler) return () => window.removeEventListener('keydown', handler) @@ -39,7 +57,22 @@ export default function Editor({ content, isDirty, onDirtyChange, onSave, filePa return (
+ {hasConflict && ( + + )} + {showSearch && ( + { + setShowSearch(false) + editor.commands.focus() + }} + /> + )}
diff --git a/src/components/Editor/SearchBar.jsx b/src/components/Editor/SearchBar.jsx new file mode 100644 index 0000000..a34cc80 --- /dev/null +++ b/src/components/Editor/SearchBar.jsx @@ -0,0 +1,115 @@ +import { useState, useEffect, useRef } from 'react' + +export default function SearchBar({ editor, onClose }) { + const [searchTerm, setSearchTerm] = useState('') + const [replaceTerm, setReplaceTerm] = useState('') + const [showReplace, setShowReplace] = useState(false) + const inputRef = useRef(null) + + useEffect(() => { + inputRef.current?.focus() + // Select any existing text in search input + inputRef.current?.select() + }, []) + + useEffect(() => { + editor.commands.setSearchTerm(searchTerm) + }, [searchTerm, editor]) + + const handleClose = () => { + editor.commands.clearSearch() + onClose() + } + + const handleKeyDown = (e) => { + if (e.key === 'Escape') { + e.preventDefault() + handleClose() + } else if (e.key === 'Enter' && !e.shiftKey) { + e.preventDefault() + editor.commands.goToNextMatch() + } else if (e.key === 'Enter' && e.shiftKey) { + e.preventDefault() + editor.commands.goToPrevMatch() + } + } + + const { results, activeIndex } = editor.storage.search + const matchCount = results.length + const currentMatch = matchCount > 0 ? activeIndex + 1 : 0 + + return ( +
+
+ + setSearchTerm(e.target.value)} + onKeyDown={handleKeyDown} + placeholder="Search..." + className="flex-1 px-2 py-1 text-sm border border-gray-300 rounded focus:outline-none focus:border-blue-400 bg-white" + /> + + {searchTerm ? `${currentMatch} of ${matchCount}` : ''} + + + + +
+ {showReplace && ( +
+ setReplaceTerm(e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Escape') handleClose() + }} + placeholder="Replace..." + className="flex-1 px-2 py-1 text-sm border border-gray-300 rounded focus:outline-none focus:border-blue-400 bg-white" + /> + + +
+ )} +
+ ) +} diff --git a/src/components/Editor/SearchExtension.js b/src/components/Editor/SearchExtension.js new file mode 100644 index 0000000..3ea5c00 --- /dev/null +++ b/src/components/Editor/SearchExtension.js @@ -0,0 +1,166 @@ +import { Extension } from '@tiptap/react' +import { Plugin, PluginKey } from '@tiptap/pm/state' +import { Decoration, DecorationSet } from '@tiptap/pm/view' + +const searchPluginKey = new PluginKey('search') + +function findMatches(doc, searchTerm) { + if (!searchTerm) return [] + const results = [] + const term = searchTerm.toLowerCase() + doc.descendants((node, pos) => { + if (!node.isText) return + const text = node.text.toLowerCase() + let index = 0 + while ((index = text.indexOf(term, index)) !== -1) { + results.push({ from: pos + index, to: pos + index + searchTerm.length }) + index += 1 + } + }) + return results +} + +export { searchPluginKey } + +export const SearchExtension = Extension.create({ + name: 'search', + + addStorage() { + return { + searchTerm: '', + activeIndex: 0, + results: [], + } + }, + + addCommands() { + return { + setSearchTerm: (term) => ({ editor }) => { + editor.storage.search.searchTerm = term + const results = findMatches(editor.state.doc, term) + editor.storage.search.results = results + // Reset active index + editor.storage.search.activeIndex = results.length > 0 ? 0 : -1 + // Force plugin to re-decorate + const { tr } = editor.state + tr.setMeta(searchPluginKey, { searchTerm: term, activeIndex: editor.storage.search.activeIndex }) + editor.view.dispatch(tr) + return true + }, + goToNextMatch: () => ({ editor }) => { + const { results, activeIndex } = editor.storage.search + if (results.length === 0) return false + const next = (activeIndex + 1) % results.length + editor.storage.search.activeIndex = next + const { tr } = editor.state + tr.setMeta(searchPluginKey, { activeIndex: next }) + editor.view.dispatch(tr) + // Scroll to match + const match = results[next] + if (match) { + editor.commands.setTextSelection(match.from) + editor.commands.scrollIntoView() + } + return true + }, + goToPrevMatch: () => ({ editor }) => { + const { results, activeIndex } = editor.storage.search + if (results.length === 0) return false + const prev = (activeIndex - 1 + results.length) % results.length + editor.storage.search.activeIndex = prev + const { tr } = editor.state + tr.setMeta(searchPluginKey, { activeIndex: prev }) + editor.view.dispatch(tr) + const match = results[prev] + if (match) { + editor.commands.setTextSelection(match.from) + editor.commands.scrollIntoView() + } + return true + }, + replaceCurrentMatch: (replacement) => ({ editor }) => { + const { results, activeIndex } = editor.storage.search + if (results.length === 0 || activeIndex < 0) return false + const match = results[activeIndex] + editor.chain() + .setTextSelection({ from: match.from, to: match.to }) + .insertContent(replacement) + .run() + // Re-run search after replace + editor.commands.setSearchTerm(editor.storage.search.searchTerm) + return true + }, + replaceAll: (replacement) => ({ editor }) => { + const { results, searchTerm } = editor.storage.search + if (results.length === 0) return false + // Replace from end to start to preserve positions + const chain = editor.chain() + for (let i = results.length - 1; i >= 0; i--) { + chain.setTextSelection({ from: results[i].from, to: results[i].to }) + chain.insertContent(replacement) + } + chain.run() + editor.commands.setSearchTerm(searchTerm) + return true + }, + clearSearch: () => ({ editor }) => { + editor.storage.search.searchTerm = '' + editor.storage.search.results = [] + editor.storage.search.activeIndex = -1 + const { tr } = editor.state + tr.setMeta(searchPluginKey, { searchTerm: '', activeIndex: -1 }) + editor.view.dispatch(tr) + return true + }, + } + }, + + addProseMirrorPlugins() { + const extension = this + + return [ + new Plugin({ + key: searchPluginKey, + state: { + init() { + return DecorationSet.empty + }, + apply(tr, oldDecos) { + const meta = tr.getMeta(searchPluginKey) + if (meta !== undefined) { + const { searchTerm } = extension.storage + if (!searchTerm) return DecorationSet.empty + const results = findMatches(tr.doc, searchTerm) + extension.storage.results = results + const activeIndex = extension.storage.activeIndex + const decorations = results.map((match, i) => { + const className = i === activeIndex ? 'search-match search-match-active' : 'search-match' + return Decoration.inline(match.from, match.to, { class: className }) + }) + return DecorationSet.create(tr.doc, decorations) + } + // If the document changed, rebuild decorations + if (tr.docChanged && extension.storage.searchTerm) { + const results = findMatches(tr.doc, extension.storage.searchTerm) + extension.storage.results = results + if (extension.storage.activeIndex >= results.length) { + extension.storage.activeIndex = results.length > 0 ? 0 : -1 + } + const decorations = results.map((match, i) => { + const className = i === extension.storage.activeIndex ? 'search-match search-match-active' : 'search-match' + return Decoration.inline(match.from, match.to, { class: className }) + }) + return DecorationSet.create(tr.doc, decorations) + } + return oldDecos.map(tr.mapping, tr.doc) + }, + }, + props: { + decorations(state) { + return this.getState(state) + }, + }, + }), + ] + }, +}) diff --git a/src/index.css b/src/index.css index b03f578..8960f77 100644 --- a/src/index.css +++ b/src/index.css @@ -60,3 +60,33 @@ html, body, #root { max-width: 100%; height: auto; } +.tiptap table { + border-collapse: collapse; + width: 100%; + margin: 0.75em 0; +} +.tiptap th, +.tiptap td { + border: 1px solid #d1d5db; + padding: 0.4em 0.75em; + text-align: left; + vertical-align: top; +} +.tiptap th { + background: #f3f4f6; + font-weight: 600; +} +.tiptap .selectedCell { + background: #dbeafe; +} + +/* Search match highlighting */ +.search-match { + background: #fef08a; + border-radius: 2px; +} +.search-match-active { + background: #f97316; + color: white; + border-radius: 2px; +} diff --git a/src/tauri-api.js b/src/tauri-api.js index 9c1aa48..e239345 100644 --- a/src/tauri-api.js +++ b/src/tauri-api.js @@ -33,6 +33,10 @@ export const gitUnpushed = () => invoke('git_unpushed') export const gitPush = () => invoke('git_push') export const gitLog = (limit = 20) => invoke('git_log', { limit }) +// File watcher +export const watchRepo = (repoPath) => invoke('watch_repo', { repoPath }) +export const unwatchRepo = () => invoke('unwatch_repo') + // Terminal export const ptySpawn = () => invoke('pty_spawn') export const ptyWrite = (data) => invoke('pty_write', { data })