Initial release: Ironpad v0.1.0 - Local-first, file-based project and knowledge management system. Rust backend, Vue 3 frontend, Milkdown editor, Git integration, cross-platform builds. Built with AI using Open Method.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
skepsismusic
2026-02-06 00:13:31 +01:00
commit ebe3e2aa8f
97 changed files with 25033 additions and 0 deletions

65
.github/workflows/ci.yml vendored Normal file
View File

@@ -0,0 +1,65 @@
name: CI
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
backend:
name: Backend (Rust)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install Rust toolchain
uses: dtolnay/rust-toolchain@stable
- name: Cache cargo
uses: actions/cache@v4
with:
path: |
~/.cargo/bin/
~/.cargo/registry/index/
~/.cargo/registry/cache/
~/.cargo/git/db/
backend/target/
key: ${{ runner.os }}-cargo-${{ hashFiles('backend/Cargo.lock') }}
- name: Check
working-directory: backend
run: cargo check
- name: Test
working-directory: backend
run: cargo test
- name: Format check
working-directory: backend
run: cargo fmt -- --check
frontend:
name: Frontend (Vue)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install Node.js
uses: actions/setup-node@v4
with:
node-version: 20
cache: npm
cache-dependency-path: frontend/package-lock.json
- name: Install dependencies
working-directory: frontend
run: npm ci
- name: Type check
working-directory: frontend
run: npx vue-tsc --noEmit
- name: Build
working-directory: frontend
run: npm run build

105
.github/workflows/release.yml vendored Normal file
View File

@@ -0,0 +1,105 @@
name: Release
on:
push:
tags:
- 'v*'
permissions:
contents: write
jobs:
build:
name: Build ${{ matrix.target }}
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
matrix:
include:
- target: x86_64-unknown-linux-gnu
os: ubuntu-latest
archive: tar.gz
- target: x86_64-apple-darwin
os: macos-latest
archive: tar.gz
- target: x86_64-pc-windows-msvc
os: windows-latest
archive: zip
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Install Rust toolchain
uses: dtolnay/rust-toolchain@stable
with:
targets: ${{ matrix.target }}
- name: Install Node.js
uses: actions/setup-node@v4
with:
node-version: 20
- name: Build frontend
working-directory: frontend
run: |
npm ci
npm run build
- name: Copy frontend to static
shell: bash
run: |
mkdir -p backend/static
cp -r frontend/dist/* backend/static/
- name: Build backend (release)
working-directory: backend
run: cargo build --release --target ${{ matrix.target }}
- name: Package (Unix)
if: matrix.archive == 'tar.gz'
shell: bash
run: |
BINARY_NAME="ironpad"
RELEASE_DIR="ironpad-${{ github.ref_name }}-${{ matrix.target }}"
mkdir -p "$RELEASE_DIR"
cp "backend/target/${{ matrix.target }}/release/$BINARY_NAME" "$RELEASE_DIR/"
cp README.md LICENSE "$RELEASE_DIR/"
tar czf "$RELEASE_DIR.tar.gz" "$RELEASE_DIR"
echo "ASSET=$RELEASE_DIR.tar.gz" >> $GITHUB_ENV
- name: Package (Windows)
if: matrix.archive == 'zip'
shell: bash
run: |
BINARY_NAME="ironpad.exe"
RELEASE_DIR="ironpad-${{ github.ref_name }}-${{ matrix.target }}"
mkdir -p "$RELEASE_DIR"
cp "backend/target/${{ matrix.target }}/release/$BINARY_NAME" "$RELEASE_DIR/"
cp README.md LICENSE "$RELEASE_DIR/"
7z a "$RELEASE_DIR.zip" "$RELEASE_DIR"
echo "ASSET=$RELEASE_DIR.zip" >> $GITHUB_ENV
- name: Upload artifact
uses: actions/upload-artifact@v4
with:
name: ironpad-${{ matrix.target }}
path: ${{ env.ASSET }}
release:
name: Create Release
needs: build
runs-on: ubuntu-latest
steps:
- name: Download all artifacts
uses: actions/download-artifact@v4
with:
path: artifacts
- name: Create GitHub Release
uses: softprops/action-gh-release@v2
with:
files: artifacts/**/*
generate_release_notes: true
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

45
.gitignore vendored Normal file
View File

@@ -0,0 +1,45 @@
# === OS ===
.DS_Store
Thumbs.db
Desktop.ini
# === Editors / IDEs ===
.idea/
.vscode/
*.swp
*.swo
*~
# === Rust ===
backend/target/
# === Node.js ===
frontend/node_modules/
frontend/dist/
frontend/.vite/
# === Build artifacts ===
backend/static/
*.tmp
# === User data (created at runtime) ===
# The data/ directory is the user's local database.
# We include only the skeleton structure (.gitkeep files),
# not the actual notes, projects, tasks, or daily notes.
data/.git/
data/.gitignore
data/inbox.md
data/index.md
data/archive/*.md
data/daily/*.md
data/notes/*.md
data/notes/assets/*
!data/notes/assets/.gitkeep
data/projects/*/
!data/projects/.gitkeep
# === Stray root lock file (frontend/package-lock.json is kept for CI) ===
/package-lock.json
# === Generated images (article assets, not source) ===
/assets/

21
LICENSE Normal file
View File

@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Ola Proeis
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

142
README.md Normal file
View File

@@ -0,0 +1,142 @@
# Ironpad
**A local-first, file-based project & knowledge management system.**
![Build](https://github.com/OlaProeis/ironPad/actions/workflows/release.yml/badge.svg)
![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)
![Platform](https://img.shields.io/badge/platform-Windows%20%7C%20macOS%20%7C%20Linux-lightgrey)
![Rust](https://img.shields.io/badge/rust-1.70%2B-orange)
![Version](https://img.shields.io/badge/version-0.1.0-green)
Ironpad stores all your notes, projects, and tasks as plain Markdown files. No cloud services, no vendor lock-in -- your data stays on your machine in a format you can read and edit with any text editor. Every change is automatically versioned with Git.
![Ironpad Screenshot](docs/screenshot.jpg)
> **v0.1.0 -- Early Release.** This is the first public release. It's functional and we use it daily, but expect rough edges. Bug reports and feature requests are welcome via [Issues](https://github.com/OlaProeis/ironPad/issues).
---
## Features
- **File-based storage** -- All data stored as Markdown files with YAML frontmatter
- **Local-first** -- Works fully offline, no internet required
- **Git integration** -- Automatic version control with 60-second commit batching, full diff viewer, push/fetch
- **WYSIWYG editing** -- Milkdown editor with real-time markdown rendering and formatting toolbar
- **Project management** -- Organize tasks and notes by project with due dates, tags, subtasks, and recurrence
- **Calendar view** -- Month grid showing tasks by due date with color-coded urgency
- **Dashboard** -- Cross-project overview with active task summaries
- **Daily notes** -- Quick capture with templates for daily journaling
- **Real-time sync** -- WebSocket-based live updates; edit in VS Code, see changes in the browser instantly
- **External editing** -- Full support for VS Code, Obsidian, Vim, or any text editor
- **Search** -- ripgrep-powered full-text search across all files (Ctrl+K)
- **Dark theme** -- Beautiful dark UI by default with light mode toggle
- **Tiny footprint** -- 5 MB binary, ~20 MB RAM, sub-second startup
## Quick Start
### Option 1: Download Release (Recommended)
1. Download the latest release for your platform from [Releases](https://github.com/OlaProeis/ironPad/releases)
2. Extract and run the executable
3. Your browser opens automatically -- start using Ironpad
Data is stored in a `data/` folder next to the executable. To use a custom location, set the `IRONPAD_DATA_DIR` environment variable.
### Option 2: Build From Source
**Prerequisites:** [Rust](https://rustup.rs/) (1.70+), [Node.js](https://nodejs.org/) (18+), [Git](https://git-scm.com/)
```bash
# Clone the repository
git clone https://github.com/OlaProeis/ironPad.git
cd ironPad
# Start the backend
cd backend
cargo run
# In a new terminal, start the frontend
cd frontend
npm install
npm run dev
```
Open http://localhost:5173 in your browser.
## Tech Stack
| Component | Technology |
|-----------|------------|
| Backend | Rust, Axum 0.8, Tokio |
| Frontend | Vue 3, Vite, TypeScript |
| Editor | Milkdown (ProseMirror-based) |
| State | Pinia |
| Routing | Vue Router |
| Data | Markdown + YAML frontmatter |
| Version Control | Git (via git2) |
| Search | ripgrep |
## Roadmap
Ironpad is under active development. Here's what's planned:
- [ ] UI polish and animations
- [ ] Tag extraction and filtering across projects
- [ ] Backlinks between notes
- [ ] Graph view of note connections
- [ ] Export to PDF / HTML
- [ ] Custom themes
- [ ] Global hotkey (Ctrl+Shift+Space)
- [ ] System tray mode
- [ ] Kanban board view for tasks
See [CHECKLIST.md](docs/ai-workflow/CHECKLIST.md) for detailed implementation status.
## Built With AI
This entire application was built using AI-assisted development -- an approach we call **Open Method**. We share not just the code, but the complete process: the PRD, task breakdowns, handover documents, and workflow artifacts.
Read about the method:
- [The AI Development Workflow I Actually Use](https://dev.to/olaproeis/the-ai-development-workflow-i-actually-use-549i) -- The original workflow article
- [docs/ai-workflow/](docs/ai-workflow/) -- Documentation of the AI-assisted development process used to build Ironpad
**Tools used:** Cursor IDE, Claude Opus 4.5/4.6, Context7 MCP
## Configuration
| Setting | Default | Description |
|---------|---------|-------------|
| Data directory | `data/` next to executable | Override with `IRONPAD_DATA_DIR` env var |
| Backend port | 3000 (auto-increments to 3010) | Dynamic port selection |
| Auto-commit | Every 60 seconds | Git commits when changes exist |
| Auto-save | 1 second debounce | Frontend saves after typing stops |
## Documentation
| Document | Description |
|----------|-------------|
| [docs/API.md](docs/API.md) | Complete REST API reference |
| [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md) | System design and technical details |
| [docs/ai-workflow/](docs/ai-workflow/) | AI development workflow and methodology |
## Contributing
This is an early release and contributions are welcome!
1. Check [Issues](https://github.com/OlaProeis/ironPad/issues) for open bugs and feature requests
2. Create a branch for your feature/fix
3. Follow the code style (`cargo fmt` for Rust)
4. Test your changes thoroughly
5. Submit a pull request
## License
[MIT License](LICENSE)
## Acknowledgments
- [Milkdown](https://milkdown.dev/) -- WYSIWYG Markdown editor
- [Axum](https://github.com/tokio-rs/axum) -- Rust web framework
- [Vue.js](https://vuejs.org/) -- Frontend framework
- [Pinia](https://pinia.vuejs.org/) -- State management
- [Anthropic Claude](https://www.anthropic.com/) -- AI-assisted development

344
ai-context.md Normal file
View File

@@ -0,0 +1,344 @@
# AI Context — Ironpad
Paste this into every new AI chat for project context.
---
## What It Is
**Ironpad** is a local-first, file-based personal project & knowledge management system.
- **Backend**: Rust (Axum 0.8, Tokio)
- **Frontend**: Vue 3 (Vite)
- **Data**: Plain Markdown files with YAML frontmatter
- **Versioning**: Local Git repository
- **UI**: System browser (no Electron)
---
## Core Principles
1. **Files are the database** — filesystem is source of truth
2. **Local-first** — works fully offline
3. **External editing supported** — VS Code, Obsidian, Vim all work
4. **Backend owns metadata**`id`, `created`, `updated` are auto-managed
5. **Low ceremony** — minimal config, no manual metadata editing
---
## Current Architecture
```
ironpad/
├── backend/ # Rust Axum server
│ └── src/
│ ├── main.rs # Server bootstrap, WebSocket, routes
│ ├── routes/ # API endpoints
│ ├── services/ # Business logic (filesystem, git, search)
│ ├── models/ # Data structures
│ ├── websocket.rs # Real-time sync
│ └── watcher.rs # File system watching
├── frontend/ # Vue 3 SPA
│ └── src/
│ ├── App.vue # Root component with router-view
│ ├── main.ts # Entry point (Pinia + Vue Router)
│ ├── router/ # Vue Router config
│ ├── stores/ # Pinia stores (notes, projects, tasks, ui, websocket, git)
│ ├── views/ # Route views (DashboardView, ProjectView, TasksView, CalendarView, DailyView)
│ ├── components/ # Reusable components (Sidebar, MarkdownEditor, etc.)
│ ├── composables/ # Vue composables (useWebSocket)
│ ├── api/ # API client (client.ts)
│ └── types/ # TypeScript types (index.ts)
└── data/ # User data (separate git repo)
├── notes/ # Standalone notes
├── projects/ # Project folders
│ └── {project}/
│ ├── index.md # Project overview
│ ├── notes/ # Project-specific notes
│ └── tasks/ # Individual task files (task-YYYYMMDD-HHMMSS.md)
├── daily/ # Daily notes (YYYY-MM-DD.md)
├── archive/ # Archived items
├── index.md # Landing page
└── inbox.md # Quick capture
```
---
## Implemented Features
### Backend
- API-only server (no frontend serving, no browser auto-open)
- Dynamic port (3000-3010)
- Notes CRUD with atomic writes
- Frontmatter auto-management
- WebSocket server for real-time sync + file locking
- File watcher (filters own saves)
- Search (ripgrep with fallback)
- Git status + auto-commit (60s batching) + push + conflict detection
- **Full Git panel** with commit history, diff viewer, custom commit messages
- Git remote info (ahead/behind tracking), fetch support
- Projects API with notes management
- **File-based Tasks API** — each task is a markdown file with frontmatter
- Fields: id, title, completed, section, priority, due_date, is_active, tags, parent_id, recurrence, recurrence_interval
- Rich text descriptions with markdown support
- Sorted by created date (stable ordering)
- **Subtasks** — tasks with `parent_id` link to a parent task
- **Tags** — YAML sequence in frontmatter, per-task labels for filtering
- **Recurring tasks** — when completing a recurring task, auto-creates next instance with advanced due date
- Daily notes API (`/api/daily`, `/api/daily/today`, `/api/daily/:date`)
- Assets API (upload + serve)
### Frontend
- Vue Router navigation
- Pinia state management
- **Milkdown WYSIWYG editor** — ProseMirror-based, renders markdown as you type
- CommonMark + GFM support (tables, strikethrough, task lists)
- **Toolbar** with formatting buttons (bold, italic, headings, links, images, code, lists, quotes)
- **Image upload** — click image button, select file, auto-uploads and inserts markdown
- History (undo/redo), clipboard, indentation plugins
- **Dark theme by default** with toggle button (persists to localStorage)
- Sidebar with Notes/Projects/Daily/Calendar sections + task counts
- Search panel (Ctrl+K)
- **Dashboard view** (home page) — all projects as cards with active task summaries
- Click project to navigate, click task to open detail
- Shows active/backlog/overdue counts per project
- **Split-panel Task view** with:
- Task list (Active/Backlog/Completed sections)
- Task detail editor with markdown descriptions
- Preview toggle for rendered markdown
- Active/Backlog toggle button
- **Due date picker** — inline date input to set/clear due dates
- Due date display with color-coded urgency
- **Tag system** — add/remove tags with autocomplete from project tags
- **Tag filter bar** — click tags to filter task list
- **Subtasks** — expandable subtasks under parent tasks, add subtask inline
- **Recurrence picker** — set daily/weekly/monthly/yearly recurrence
- Inline title editing (double-click)
- **Calendar view** — month grid showing tasks by due date
- Tasks with due dates plotted on calendar cells
- Daily notes shown as blue dots
- Color-coded urgency (overdue, today, soon)
- Month navigation + Today button
- Click task to navigate to detail, click date to open daily note
- **Split-panel Notes view** (per project)
- **Git panel** — slide-out panel with:
- Commit history with expandable diffs
- Working directory changes with line-by-line diff
- Custom commit messages (Ctrl+Enter to commit)
- Push/Fetch buttons with ahead/behind indicators
- File status icons (added/modified/deleted/renamed)
- Git status indicator in sidebar (click to open panel)
- WebSocket real-time updates
- File lock banners (read-only mode when locked)
- Conflict warning banner
- Fullscreen layout (uses all available space)
### API Endpoints
```
GET/POST /api/notes
GET/PUT/DEL /api/notes/:id
GET/POST /api/projects
GET/PUT /api/projects/:id
GET/PUT /api/projects/:id/content
# Project Notes (file-based)
GET/POST /api/projects/:id/notes
GET/PUT/DEL /api/projects/:id/notes/:note_id
# Project Tasks (file-based, each task is a .md file)
GET/POST /api/projects/:id/tasks
GET/PUT/DEL /api/projects/:id/tasks/:task_id
PUT /api/projects/:id/tasks/:task_id/toggle
PUT /api/projects/:id/tasks/:task_id/meta
GET /api/tasks # All tasks across projects
GET /api/daily
GET /api/daily/today
GET/POST /api/daily/:date
POST /api/assets/upload
GET /api/assets/:project/:file
GET /api/search?q=
GET /api/git/status
POST /api/git/commit
POST /api/git/push
GET /api/git/conflicts
GET /api/git/log # Commit history (limit param)
GET /api/git/diff # Working directory diff
GET /api/git/diff/:commit_id # Diff for specific commit
GET /api/git/remote # Remote repository info
POST /api/git/fetch # Fetch from remote
WS /ws
```
---
## Implemented in Phase 3
- CodeMirror 6 editor with markdown syntax highlighting
- Markdown preview with split view
- Vue Router for navigation (`/`, `/projects/:id`, `/projects/:id/tasks`, `/calendar`, `/daily`)
- Pinia state management (notes, projects, tasks, ui, websocket, git stores)
- Project-specific task view with toggle and add functionality
- File locking via WebSocket (Task View vs Editor)
- Daily notes (`data/daily/`) with templates
- Assets upload API (`/api/assets/upload`, `/api/assets/:project/:file`)
- Git push and conflict detection (`/api/git/push`, `/api/git/conflicts`)
---
## Implemented in Phase 4
- **File-based tasks** — each task is a separate markdown file with YAML frontmatter
- Supports rich text descriptions with images
- New fields: `due_date`, `is_active`
- Task files stored in `projects/{id}/tasks/task-YYYYMMDD-HHMMSS.md`
- **Split-panel task view** — list on left, detail editor on right
- **Markdown preview toggle** — side-by-side raw/rendered view
- **Active/Backlog toggle** — button to move tasks between states
- **Project notes** — separate notes folder per project with split-panel view
- **Stable list sorting** — sorted by created date (not updated)
- **Backend API-only mode** — no frontend serving, no browser auto-open
- **Fullscreen layout** — uses all available browser space
---
## Known Issues / Technical Debt
1. **Project index note ID format**: Project notes use `{slug}-index` as their ID (e.g., `ferrite-index`). Projects created before this fix have incorrect IDs in frontmatter.
2. **Axum nested route limitation**: Path parameters from parent routes are NOT automatically available in nested route handlers. Project task routes use explicit routes instead of `.nest()`.
3. **Some warnings remain**: Unused methods in `locks.rs` and `git.rs` (reserved for future use).
---
## Implemented in Phase 5
- **Dashboard view** — cross-project home page with task summaries per project
- **Tags system** — per-task tags stored in frontmatter, filter bar in task list, autocomplete
- **Subtasks** — tasks with `parent_id`, grouped under parents in list, inline creation
- **Recurring tasks** — daily/weekly/monthly/yearly with auto-creation on completion
- **Calendar view** — month grid with tasks by due date + daily note indicators
- **Due date picker** — inline date input in task detail panel
- **Clickable app title** — "Ironpad" navigates to dashboard
---
## Not Yet Implemented (Phase 6+)
- UI polish and animations
- Responsive sidebar
- Global hotkey (Ctrl+Shift+Space)
- System tray mode
- Backlinks between notes
- Graph view
- Export (PDF / HTML)
- Custom themes
- Tantivy search (if >5000 notes)
- Production packaging (Tauri or similar)
- Task dependencies (blocked by)
- Time estimates on tasks
- Calendar drag-and-drop rescheduling
- Week/day calendar views
---
## Key Technical Decisions
| Decision | Choice |
|----------|--------|
| Data path | `../data` relative to backend |
| Port | Dynamic 3000-3010 |
| Auto-save | 1s debounce in frontend |
| Git commits | 60s batch + manual button |
| File watcher | notify crate, 500ms debounce |
| Search | ripgrep CLI, fallback to manual |
| Frontmatter | serde_yaml, auto-generated IDs |
| Editor | Milkdown (WYSIWYG ProseMirror-based) |
| Editor Legacy | CodeMirror 6 (MarkdownEditor.vue, kept for reference) |
| State management | Pinia stores |
| Routing | Vue Router (history mode) |
| File locking | WebSocket-based, per-client locks |
| Project note ID | `{slug}-index` format |
| Task storage | Individual .md files in `tasks/` folder |
| List sorting | By created date (stable, not affected by edits) |
| Backend mode | API-only (no frontend serving) |
| Theme | Dark by default, toggle to light, persists to localStorage |
| Tags | YAML sequence in frontmatter, project-scoped filtering |
| Subtasks | Separate task files with `parent_id` field linking to parent |
| Recurring tasks | On completion, backend auto-creates next instance with advanced due date |
| Calendar | Pure frontend month grid, tasks filtered by `due_date` presence |
| Dashboard | Home route `/`, loads all projects + all tasks for cross-project summary |
---
## Critical: Milkdown Editor Lifecycle
The Milkdown editor requires careful handling when switching between notes/tasks:
**Components:**
- `MilkdownEditor.vue` — Wrapper with `:key` prop for recreation
- `MilkdownEditorCore.vue` — Actual editor using `useEditor` hook from `@milkdown/vue`
**Pattern for switching content:**
```javascript
// Views use a separate editorKey ref (not the noteId/taskId directly)
// Content MUST be set BEFORE updating editorKey
// CORRECT order:
editorContent.value = loadedContent // Set content first
editorKey.value = noteId // Then trigger editor recreation
// WRONG order (causes stale content):
editorKey.value = noteId // Editor recreates with empty/stale defaultValue
editorContent.value = loadedContent // Too late - editor already initialized
```
**Why:** The editor uses `defaultValue` from props at creation time. If the key changes before content is set, the editor initializes with wrong content and `replaceAll()` updates may fail during the async initialization window.
**State in MilkdownEditorCore must be refs, not module-level variables** — ensures clean state on component recreation.
---
## Development Commands
```bash
# Backend (from backend/)
cargo run # API server on :3000 (no GUI)
# Frontend (from frontend/)
npm run dev # Dev server on :5173 (connects to backend on :3000)
npm run build # Build to dist/
# Development: Run both backend and frontend separately
# Backend is API-only, does not serve frontend
```
---
## Documentation
For detailed information, see:
| Document | Description |
|----------|-------------|
| `/README.md` | Project overview, quick start, installation |
| `/frontend/README.md` | Frontend architecture, component structure, Milkdown editor patterns |
| `/docs/ARCHITECTURE.md` | System design, service layer, data models, security considerations |
| `/docs/API.md` | Complete REST API reference with examples |
| `/HANDOVER.md` | Session handover notes, recent fixes, context for continuing work |
| `/CHECKLIST.md` | Current implementation status and progress |
| `/PRD.md` | Full product requirements document |
---
## Rules for AI
- Prefer incremental, verifiable changes
- File system is source of truth
- No databases, no cloud services
- Windows + PowerShell environment
- Rust 2021 edition
- Check CHECKLIST.md for current status
- Check PRD.md for full requirements
- Check HANDOVER.md for recent session context

1
backend/.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
/target

2342
backend/Cargo.lock generated Normal file

File diff suppressed because it is too large Load Diff

49
backend/Cargo.toml Normal file
View File

@@ -0,0 +1,49 @@
[package]
name = "ironpad"
version = "0.1.0"
edition = "2021"
[dependencies]
# Web framework
axum = { version = "0.8", features = ["ws", "multipart"] }
tokio = { version = "1", features = ["full"] }
tower = "0.5"
tower-http = { version = "0.6", features = ["cors", "normalize-path", "fs"] }
# Browser opening (production mode)
webbrowser = "1.0"
# Serialization
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
serde_yaml = "0.9"
# Markdown parsing (CommonMark)
markdown = "1.0.0-alpha.22"
# Git operations
git2 = "0.19"
# File system watching
notify = "6.1"
notify-debouncer-full = "0.3"
# Search (ripgrep internals)
grep = "0.3"
walkdir = "2.4"
# Date/time
chrono = { version = "0.4", features = ["serde"] }
# Logging
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
# WebSocket support
futures-util = "0.3"
uuid = { version = "1.0", features = ["v4"] }
# Utilities
lazy_static = "1.4"
tokio-util = { version = "0.7", features = ["io"] }

40
backend/src/config.rs Normal file
View File

@@ -0,0 +1,40 @@
use std::path::{Path, PathBuf};
use std::sync::OnceLock;
/// Resolved data directory path.
/// Priority: IRONPAD_DATA_DIR env var > auto-detect (production vs development).
static DATA_DIR: OnceLock<PathBuf> = OnceLock::new();
/// Initialize the data directory path. Call once at startup.
///
/// Resolution order:
/// 1. `IRONPAD_DATA_DIR` environment variable (if set)
/// 2. `./data` if `static/index.html` exists (production mode)
/// 3. `../data` (development mode, binary runs from backend/)
pub fn init_data_dir() {
let path = if let Ok(custom) = std::env::var("IRONPAD_DATA_DIR") {
tracing::info!("Using custom data directory from IRONPAD_DATA_DIR");
PathBuf::from(custom)
} else if Path::new("static/index.html").exists() {
// Production mode: data/ is next to the binary
PathBuf::from("data")
} else {
// Development mode: binary runs from backend/, data/ is one level up
PathBuf::from("../data")
};
// Create the data directory if it doesn't exist
if !path.exists() {
if let Err(e) = std::fs::create_dir_all(&path) {
tracing::error!("Failed to create data directory {}: {}", path.display(), e);
}
}
tracing::info!("Data directory: {}", path.display());
DATA_DIR.set(path).expect("Data directory already initialized");
}
/// Get the resolved data directory path.
pub fn data_dir() -> &'static Path {
DATA_DIR.get().expect("Data directory not initialized. Call config::init_data_dir() first.")
}

130
backend/src/main.rs Normal file
View File

@@ -0,0 +1,130 @@
use std::net::SocketAddr;
use std::path::Path;
use std::sync::Arc;
use axum::{routing::get, Router};
use tokio::net::TcpListener;
use tower_http::cors::CorsLayer;
use tower_http::services::ServeDir;
use tracing::{info, warn};
pub mod config;
mod models;
mod routes;
mod services;
mod watcher;
mod websocket;
/// Find an available port and return the bound listener.
/// Avoids TOCTOU race by keeping the listener alive.
async fn find_available_port() -> (TcpListener, u16) {
for port in 3000..=3010 {
let addr = SocketAddr::from(([127, 0, 0, 1], port));
if let Ok(listener) = TcpListener::bind(addr).await {
return (listener, port);
}
}
panic!("No available ports in range 30003010");
}
#[tokio::main]
async fn main() {
// Logging
tracing_subscriber::fmt().init();
// Resolve data directory (production vs development mode)
config::init_data_dir();
// Find port and bind (listener kept alive to avoid race condition)
let (listener, port) = find_available_port().await;
// WebSocket state (shared across handlers)
let ws_state = Arc::new(websocket::WsState::new());
// Start file watcher
let ws_state_clone = ws_state.clone();
if let Err(e) = watcher::start_watcher(ws_state_clone).await {
warn!("File watcher failed to start: {}", e);
}
// Initialize git repo if needed
if let Err(e) = services::git::init_repo() {
warn!("Git init skipped: {}", e);
}
// Start auto-commit background task (tries to commit every 60s)
services::git::start_auto_commit();
// CORS layer (permissive for local-only app)
let cors = CorsLayer::permissive();
// API router
let api_router = Router::new()
// Notes CRUD
.route(
"/notes",
get(routes::notes::list_notes).post(routes::notes::create_note),
)
.nest("/notes", routes::notes::router())
// Tasks
.nest("/tasks", routes::tasks::router())
// Search
.nest("/search", routes::search::router())
// Git
.nest("/git", routes::git::router())
// Projects
.nest("/projects", routes::projects::router())
// Daily notes
.nest("/daily", routes::daily::router())
// Assets
.nest("/assets", routes::assets::router());
// App router with WebSocket state
let mut app = Router::new()
.route("/health", get(|| async { "ok" }))
.route(
"/ws",
get({
let ws = ws_state.clone();
move |upgrade: axum::extract::WebSocketUpgrade| {
websocket::ws_handler(upgrade, axum::extract::State(ws))
}
}),
)
.nest("/api", api_router)
.layer(cors);
// Check for embedded frontend (production mode)
let static_dir = Path::new("static");
let has_frontend = static_dir.join("index.html").exists();
if has_frontend {
// Production mode: serve frontend from static/ and use SPA fallback
info!("Production mode: serving frontend from static/");
let serve_dir = ServeDir::new("static")
.fallback(tower_http::services::ServeFile::new("static/index.html"));
app = app.fallback_service(serve_dir);
} else {
// Development mode: API-only
app = app.fallback(|| async {
"Ironpad API server running. Use 'npm run dev' in frontend/ for the GUI."
});
}
// Start server
info!("🚀 Ironpad running on http://localhost:{port}");
// Auto-open browser in production mode
if has_frontend {
let url = format!("http://localhost:{}", port);
tokio::spawn(async move {
// Small delay to ensure server is ready
tokio::time::sleep(std::time::Duration::from_millis(300)).await;
if let Err(e) = webbrowser::open(&url) {
tracing::warn!("Failed to open browser: {}. Open http://localhost:{} manually.", e, port);
}
});
}
axum::serve(listener, app).await.expect("Server failed");
}

View File

@@ -0,0 +1,3 @@
pub mod note;
pub mod project;
pub mod task;

View File

@@ -0,0 +1,23 @@
use serde::Serialize;
/// Lightweight note representation for list views.
/// Read-only, derived from filesystem + frontmatter.
#[derive(Debug, Serialize)]
pub struct NoteSummary {
pub id: String,
pub title: String,
pub path: String,
pub note_type: String,
pub updated: Option<String>,
}
/// Full note payload for editor view.
/// Returned by GET /api/notes/:id
#[derive(Debug, Serialize)]
pub struct Note {
pub id: String,
pub path: String,
pub note_type: String,
pub frontmatter: serde_yaml::Mapping,
pub content: String,
}

View File

@@ -0,0 +1,3 @@
// Project structs are defined inline in routes/projects.rs
// because they are tightly coupled to the API response shape.
// This module is kept as a placeholder for future shared types.

View File

@@ -0,0 +1,3 @@
// Task structs are defined inline in routes/tasks.rs
// because they are tightly coupled to the API response shape.
// This module is kept as a placeholder for future shared types.

View File

@@ -0,0 +1,265 @@
use axum::{
body::Body,
extract::{Multipart, Path, Query},
http::{header, StatusCode},
response::IntoResponse,
routing::{get, post},
Json, Router,
};
use serde::{Deserialize, Serialize};
use std::fs;
use std::io::Write;
use std::path::Path as StdPath;
use tokio_util::io::ReaderStream;
use crate::config;
const MAX_FILE_SIZE: usize = 10 * 1024 * 1024; // 10MB
#[derive(Debug, Deserialize)]
pub struct UploadQuery {
pub project: Option<String>,
}
#[derive(Debug, Serialize)]
pub struct UploadResponse {
pub url: String,
pub filename: String,
pub size: usize,
}
pub fn router() -> Router {
Router::new()
.route("/upload", post(upload_asset))
.route("/{project}/{filename}", get(get_asset))
}
async fn upload_asset(
Query(query): Query<UploadQuery>,
mut multipart: Multipart,
) -> impl IntoResponse {
// Determine target directory
let assets_dir = if let Some(project_id) = &query.project {
config::data_dir()
.join("projects")
.join(project_id)
.join("assets")
} else {
config::data_dir().join("notes").join("assets")
};
// Create assets directory if it doesn't exist
if !assets_dir.exists() {
if let Err(e) = fs::create_dir_all(&assets_dir) {
return (
StatusCode::INTERNAL_SERVER_ERROR,
format!("Failed to create assets directory: {}", e),
)
.into_response();
}
}
// Process uploaded file
while let Ok(Some(field)) = multipart.next_field().await {
let name = field.name().unwrap_or("file").to_string();
if name != "file" {
continue;
}
let original_filename = field
.file_name()
.map(|s| s.to_string())
.unwrap_or_else(|| format!("upload_{}", chrono::Utc::now().timestamp()));
// Validate file type (images only for now)
let content_type = field
.content_type()
.map(|s| s.to_string())
.unwrap_or_default();
if !is_allowed_content_type(&content_type) {
return (
StatusCode::BAD_REQUEST,
format!("Unsupported file type: {}. Only images are allowed.", content_type),
)
.into_response();
}
// Read file data
let data = match field.bytes().await {
Ok(bytes) => bytes,
Err(e) => {
return (
StatusCode::BAD_REQUEST,
format!("Failed to read file data: {}", e),
)
.into_response();
}
};
// Check file size
if data.len() > MAX_FILE_SIZE {
return (
StatusCode::BAD_REQUEST,
format!("File too large. Maximum size is {} MB.", MAX_FILE_SIZE / 1024 / 1024),
)
.into_response();
}
// Generate unique filename if needed
let filename = generate_unique_filename(&assets_dir, &original_filename);
let file_path = assets_dir.join(&filename);
// Write file
let mut file = match fs::File::create(&file_path) {
Ok(f) => f,
Err(e) => {
return (
StatusCode::INTERNAL_SERVER_ERROR,
format!("Failed to create file: {}", e),
)
.into_response();
}
};
if let Err(e) = file.write_all(&data) {
return (
StatusCode::INTERNAL_SERVER_ERROR,
format!("Failed to write file: {}", e),
)
.into_response();
}
// Build response URL
let project_part = query.project.as_deref().unwrap_or("notes");
let url = format!("/api/assets/{}/{}", project_part, filename);
return (
StatusCode::CREATED,
Json(UploadResponse {
url,
filename,
size: data.len(),
}),
)
.into_response();
}
(StatusCode::BAD_REQUEST, "No file provided").into_response()
}
/// Validate that a path component doesn't contain directory traversal
fn validate_path_component(component: &str) -> Result<(), String> {
if component.contains("..") || component.contains('/') || component.contains('\\') || component.is_empty() {
return Err("Invalid path component".to_string());
}
Ok(())
}
async fn get_asset(Path((project, filename)): Path<(String, String)>) -> impl IntoResponse {
// Validate path components to prevent directory traversal
if validate_path_component(&project).is_err() || validate_path_component(&filename).is_err() {
return (StatusCode::BAD_REQUEST, "Invalid path").into_response();
}
// Determine file path
let file_path = if project == "notes" {
config::data_dir()
.join("notes")
.join("assets")
.join(&filename)
} else {
config::data_dir()
.join("projects")
.join(&project)
.join("assets")
.join(&filename)
};
// Check if file exists
if !file_path.exists() {
return (StatusCode::NOT_FOUND, "Asset not found").into_response();
}
// Read file
let file = match tokio::fs::File::open(&file_path).await {
Ok(f) => f,
Err(e) => {
return (
StatusCode::INTERNAL_SERVER_ERROR,
format!("Failed to open file: {}", e),
)
.into_response();
}
};
// Determine content type
let content_type = get_content_type(&filename);
// Stream file response
let stream = ReaderStream::new(file);
let body = Body::from_stream(stream);
(
StatusCode::OK,
[(header::CONTENT_TYPE, content_type)],
body,
)
.into_response()
}
fn is_allowed_content_type(content_type: &str) -> bool {
matches!(
content_type,
"image/jpeg"
| "image/png"
| "image/gif"
| "image/webp"
| "image/svg+xml"
| "application/pdf"
)
}
fn get_content_type(filename: &str) -> &'static str {
let ext = filename
.rsplit('.')
.next()
.unwrap_or("")
.to_lowercase();
match ext.as_str() {
"jpg" | "jpeg" => "image/jpeg",
"png" => "image/png",
"gif" => "image/gif",
"webp" => "image/webp",
"svg" => "image/svg+xml",
"pdf" => "application/pdf",
_ => "application/octet-stream",
}
}
fn generate_unique_filename(dir: &StdPath, original: &str) -> String {
// Extract name and extension
let (name, ext) = if let Some(dot_idx) = original.rfind('.') {
(&original[..dot_idx], &original[dot_idx..])
} else {
(original, "")
};
// Sanitize filename
let sanitized_name: String = name
.chars()
.map(|c| if c.is_alphanumeric() || c == '-' || c == '_' { c } else { '_' })
.collect();
let base_filename = format!("{}{}", sanitized_name, ext);
let target_path = dir.join(&base_filename);
// If file doesn't exist, use original name
if !target_path.exists() {
return base_filename;
}
// Otherwise, add timestamp
let timestamp = chrono::Utc::now().timestamp_millis();
format!("{}_{}{}", sanitized_name, timestamp, ext)
}

319
backend/src/routes/daily.rs Normal file
View File

@@ -0,0 +1,319 @@
use axum::{
body::Bytes,
extract::Path,
http::StatusCode,
response::IntoResponse,
routing::get,
Json, Router,
};
use chrono::{NaiveDate, Utc};
use serde::{Deserialize, Serialize};
use std::fs;
use crate::services::filesystem;
use crate::config;
use crate::services::frontmatter;
#[derive(Debug, Serialize)]
pub struct DailyNote {
pub id: String,
pub date: String,
pub path: String,
pub content: String,
pub frontmatter: serde_yaml::Mapping,
}
#[derive(Debug, Serialize)]
pub struct DailyNoteSummary {
pub id: String,
pub date: String,
pub path: String,
pub title: String,
}
pub fn router() -> Router {
Router::new()
.route("/", get(list_daily_notes))
.route("/today", get(get_or_create_today))
.route("/{date}", get(get_daily_note).post(create_daily_note).put(update_daily_note))
}
/// List all daily notes
async fn list_daily_notes() -> impl IntoResponse {
match list_daily_notes_impl() {
Ok(notes) => Json(notes).into_response(),
Err(err) => (
StatusCode::INTERNAL_SERVER_ERROR,
format!("Failed to list daily notes: {}", err),
)
.into_response(),
}
}
fn list_daily_notes_impl() -> Result<Vec<DailyNoteSummary>, String> {
let daily_dir = config::data_dir().join("daily");
// Create directory if it doesn't exist
if !daily_dir.exists() {
fs::create_dir_all(&daily_dir).map_err(|e| e.to_string())?;
return Ok(Vec::new());
}
let mut notes = Vec::new();
for entry in fs::read_dir(&daily_dir).map_err(|e| e.to_string())? {
let entry = entry.map_err(|e| e.to_string())?;
let path = entry.path();
if path.extension().and_then(|s| s.to_str()) != Some("md") {
continue;
}
let filename = path.file_stem().and_then(|s| s.to_str()).unwrap_or("");
// Validate date format
if NaiveDate::parse_from_str(filename, "%Y-%m-%d").is_err() {
continue;
}
let content = fs::read_to_string(&path).map_err(|e| e.to_string())?;
let (fm, _, _) = frontmatter::parse_frontmatter(&content);
let title = fm
.get(&serde_yaml::Value::from("title"))
.and_then(|v| v.as_str())
.map(String::from)
.unwrap_or_else(|| filename.to_string());
notes.push(DailyNoteSummary {
id: format!("daily-{}", filename),
date: filename.to_string(),
path: format!("daily/{}.md", filename),
title,
});
}
// Sort by date descending
notes.sort_by(|a, b| b.date.cmp(&a.date));
Ok(notes)
}
/// Get or create today's daily note
async fn get_or_create_today() -> impl IntoResponse {
let today = Utc::now().format("%Y-%m-%d").to_string();
match get_daily_note_impl(&today) {
Ok(note) => Json(note).into_response(),
Err(_) => {
// Note doesn't exist, create it with default template
match create_daily_note_impl(&today, None) {
Ok(note) => (StatusCode::CREATED, Json(note)).into_response(),
Err(err) => (
StatusCode::INTERNAL_SERVER_ERROR,
format!("Failed to create today's note: {}", err),
)
.into_response(),
}
}
}
}
/// Get a daily note by date
async fn get_daily_note(Path(date): Path<String>) -> impl IntoResponse {
// Validate date format
if NaiveDate::parse_from_str(&date, "%Y-%m-%d").is_err() {
return (StatusCode::BAD_REQUEST, "Invalid date format. Use YYYY-MM-DD").into_response();
}
match get_daily_note_impl(&date) {
Ok(note) => Json(note).into_response(),
Err(err) if err.contains("not found") => {
(StatusCode::NOT_FOUND, err).into_response()
}
Err(err) => (
StatusCode::INTERNAL_SERVER_ERROR,
format!("Failed to get daily note: {}", err),
)
.into_response(),
}
}
fn get_daily_note_impl(date: &str) -> Result<DailyNote, String> {
let daily_dir = config::data_dir().join("daily");
let note_path = daily_dir.join(format!("{}.md", date));
if !note_path.exists() {
return Err(format!("Daily note not found: {}", date));
}
let content = fs::read_to_string(&note_path).map_err(|e| e.to_string())?;
let (fm, body, _) = frontmatter::parse_frontmatter(&content);
Ok(DailyNote {
id: format!("daily-{}", date),
date: date.to_string(),
path: format!("daily/{}.md", date),
content: body,
frontmatter: fm,
})
}
#[derive(Debug, Deserialize)]
pub struct CreateDailyNoteRequest {
pub content: Option<String>,
}
/// Create a daily note (optionally with content)
async fn create_daily_note(
Path(date): Path<String>,
body: Option<Json<CreateDailyNoteRequest>>,
) -> impl IntoResponse {
// Validate date format
if NaiveDate::parse_from_str(&date, "%Y-%m-%d").is_err() {
return (StatusCode::BAD_REQUEST, "Invalid date format. Use YYYY-MM-DD").into_response();
}
let content = body.and_then(|b| b.content.clone());
match create_daily_note_impl(&date, content.as_deref()) {
Ok(note) => (StatusCode::CREATED, Json(note)).into_response(),
Err(err) if err.contains("already exists") => {
(StatusCode::CONFLICT, err).into_response()
}
Err(err) => (
StatusCode::INTERNAL_SERVER_ERROR,
format!("Failed to create daily note: {}", err),
)
.into_response(),
}
}
fn create_daily_note_impl(date: &str, initial_content: Option<&str>) -> Result<DailyNote, String> {
let daily_dir = config::data_dir().join("daily");
// Create directory if it doesn't exist
if !daily_dir.exists() {
fs::create_dir_all(&daily_dir).map_err(|e| e.to_string())?;
}
let note_path = daily_dir.join(format!("{}.md", date));
if note_path.exists() {
return Err(format!("Daily note already exists: {}", date));
}
let now = Utc::now().to_rfc3339();
// Parse date for display
let parsed_date = NaiveDate::parse_from_str(date, "%Y-%m-%d")
.map_err(|e| e.to_string())?;
let display_date = parsed_date.format("%A, %B %d, %Y").to_string();
// Create frontmatter
let mut fm = serde_yaml::Mapping::new();
fm.insert(
serde_yaml::Value::from("id"),
serde_yaml::Value::from(format!("daily-{}", date)),
);
fm.insert(
serde_yaml::Value::from("type"),
serde_yaml::Value::from("daily"),
);
fm.insert(
serde_yaml::Value::from("title"),
serde_yaml::Value::from(display_date.clone()),
);
fm.insert(
serde_yaml::Value::from("date"),
serde_yaml::Value::from(date),
);
fm.insert(
serde_yaml::Value::from("created"),
serde_yaml::Value::from(now.clone()),
);
fm.insert(
serde_yaml::Value::from("updated"),
serde_yaml::Value::from(now),
);
// Use provided content or default template
let body = initial_content
.map(|c| c.to_string())
.unwrap_or_else(|| {
format!(
"# {}\n\n## Today's Focus\n\n- \n\n## Notes\n\n\n\n## Tasks\n\n- [ ] \n",
display_date
)
});
let content = frontmatter::serialize_frontmatter(&fm, &body)?;
filesystem::atomic_write(&note_path, content.as_bytes())?;
Ok(DailyNote {
id: format!("daily-{}", date),
date: date.to_string(),
path: format!("daily/{}.md", date),
content: body,
frontmatter: fm,
})
}
/// Update a daily note's content
async fn update_daily_note(
Path(date): Path<String>,
body: Bytes,
) -> impl IntoResponse {
// Validate date format
if NaiveDate::parse_from_str(&date, "%Y-%m-%d").is_err() {
return (StatusCode::BAD_REQUEST, "Invalid date format. Use YYYY-MM-DD").into_response();
}
let content = String::from_utf8_lossy(&body).to_string();
match update_daily_note_impl(&date, &content) {
Ok(note) => Json(note).into_response(),
Err(err) if err.contains("not found") => {
(StatusCode::NOT_FOUND, err).into_response()
}
Err(err) => (
StatusCode::INTERNAL_SERVER_ERROR,
format!("Failed to update daily note: {}", err),
)
.into_response(),
}
}
fn update_daily_note_impl(date: &str, new_content: &str) -> Result<DailyNote, String> {
let daily_dir = config::data_dir().join("daily");
let note_path = daily_dir.join(format!("{}.md", date));
if !note_path.exists() {
return Err(format!("Daily note not found: {}", date));
}
// Read existing file to preserve frontmatter
let existing_content = fs::read_to_string(&note_path).map_err(|e| e.to_string())?;
let (mut fm, _, _) = frontmatter::parse_frontmatter(&existing_content);
// Update the 'updated' timestamp
let now = Utc::now().to_rfc3339();
fm.insert(
serde_yaml::Value::from("updated"),
serde_yaml::Value::from(now),
);
// Serialize with updated frontmatter and new content (atomic write)
let file_content = frontmatter::serialize_frontmatter(&fm, new_content)?;
filesystem::atomic_write(&note_path, file_content.as_bytes())?;
Ok(DailyNote {
id: format!("daily-{}", date),
date: date.to_string(),
path: format!("daily/{}.md", date),
content: new_content.to_string(),
frontmatter: fm,
})
}

184
backend/src/routes/git.rs Normal file
View File

@@ -0,0 +1,184 @@
use axum::{
extract::{Path, Query},
http::StatusCode,
response::IntoResponse,
routing::{get, post},
Json, Router,
};
use serde::{Deserialize, Serialize};
use crate::services::git;
pub fn router() -> Router {
Router::new()
.route("/status", get(get_status))
.route("/commit", post(commit))
.route("/init", post(init_repo))
.route("/conflicts", get(get_conflicts))
.route("/push", post(push))
.route("/log", get(get_log))
.route("/diff", get(get_working_diff))
.route("/diff/{commit_id}", get(get_commit_diff))
.route("/remote", get(get_remote))
.route("/fetch", post(fetch))
}
async fn get_status() -> impl IntoResponse {
match git::get_status() {
Ok(status) => Json(status).into_response(),
Err(err) => (
StatusCode::INTERNAL_SERVER_ERROR,
format!("Failed to get git status: {}", err),
)
.into_response(),
}
}
#[derive(Debug, Deserialize)]
pub struct CommitRequest {
message: Option<String>,
}
async fn commit(Json(payload): Json<CommitRequest>) -> impl IntoResponse {
match git::commit_all(payload.message.as_deref()) {
Ok(info) => (StatusCode::CREATED, Json(info)).into_response(),
Err(err) => (StatusCode::BAD_REQUEST, err).into_response(),
}
}
async fn init_repo() -> impl IntoResponse {
match git::init_repo() {
Ok(_) => StatusCode::OK.into_response(),
Err(err) => (
StatusCode::INTERNAL_SERVER_ERROR,
format!("Failed to init repo: {}", err),
)
.into_response(),
}
}
async fn get_conflicts() -> impl IntoResponse {
match git::check_conflicts() {
Ok(conflicts) => Json(conflicts).into_response(),
Err(err) => (
StatusCode::INTERNAL_SERVER_ERROR,
format!("Failed to check conflicts: {}", err),
)
.into_response(),
}
}
#[derive(Debug, Serialize)]
struct PushResponse {
success: bool,
message: String,
}
async fn push() -> impl IntoResponse {
// Check if remote is configured
if !git::has_remote() {
return (
StatusCode::BAD_REQUEST,
Json(PushResponse {
success: false,
message: "No remote repository configured. Add a remote with: git remote add origin <url>".to_string(),
}),
)
.into_response();
}
match git::push_to_remote() {
Ok(()) => (
StatusCode::OK,
Json(PushResponse {
success: true,
message: "Successfully pushed to remote".to_string(),
}),
)
.into_response(),
Err(err) => (
StatusCode::INTERNAL_SERVER_ERROR,
Json(PushResponse {
success: false,
message: err,
}),
)
.into_response(),
}
}
#[derive(Debug, Deserialize)]
pub struct LogQuery {
limit: Option<usize>,
}
async fn get_log(Query(query): Query<LogQuery>) -> impl IntoResponse {
match git::get_log(query.limit) {
Ok(commits) => Json(commits).into_response(),
Err(err) => (
StatusCode::INTERNAL_SERVER_ERROR,
format!("Failed to get git log: {}", err),
)
.into_response(),
}
}
async fn get_working_diff() -> impl IntoResponse {
match git::get_working_diff() {
Ok(diff) => Json(diff).into_response(),
Err(err) => (
StatusCode::INTERNAL_SERVER_ERROR,
format!("Failed to get diff: {}", err),
)
.into_response(),
}
}
async fn get_commit_diff(Path(commit_id): Path<String>) -> impl IntoResponse {
match git::get_commit_diff(&commit_id) {
Ok(diff) => Json(diff).into_response(),
Err(err) => (
StatusCode::INTERNAL_SERVER_ERROR,
format!("Failed to get commit diff: {}", err),
)
.into_response(),
}
}
async fn get_remote() -> impl IntoResponse {
match git::get_remote_info() {
Ok(info) => Json(info).into_response(),
Err(err) => (
StatusCode::INTERNAL_SERVER_ERROR,
format!("Failed to get remote info: {}", err),
)
.into_response(),
}
}
#[derive(Debug, Serialize)]
struct FetchResponse {
success: bool,
message: String,
}
async fn fetch() -> impl IntoResponse {
match git::fetch_from_remote() {
Ok(()) => (
StatusCode::OK,
Json(FetchResponse {
success: true,
message: "Successfully fetched from remote".to_string(),
}),
)
.into_response(),
Err(err) => (
StatusCode::INTERNAL_SERVER_ERROR,
Json(FetchResponse {
success: false,
message: err,
}),
)
.into_response(),
}
}

View File

@@ -0,0 +1,7 @@
pub mod assets;
pub mod daily;
pub mod git;
pub mod notes;
pub mod projects;
pub mod search;
pub mod tasks;

View File

@@ -0,0 +1,82 @@
use axum::{
extract::Path,
http::StatusCode,
response::IntoResponse,
routing::get,
Json, Router,
};
use crate::models::note::{Note, NoteSummary};
use crate::services::filesystem;
pub fn router() -> Router {
Router::new()
.route("/{id}", get(get_note).put(update_note).delete(delete_note))
}
pub async fn list_notes() -> impl IntoResponse {
match filesystem::list_notes() {
Ok(notes) => Json::<Vec<NoteSummary>>(notes).into_response(),
Err(err) => (
StatusCode::INTERNAL_SERVER_ERROR,
format!("Failed to list notes: {}", err),
)
.into_response(),
}
}
async fn get_note(Path(id): Path<String>) -> impl IntoResponse {
match filesystem::read_note_by_id(&id) {
Ok(note) => Json::<Note>(note).into_response(),
Err(err) if err.starts_with("Note not found") => {
(StatusCode::NOT_FOUND, err).into_response()
}
Err(err) => (
StatusCode::INTERNAL_SERVER_ERROR,
format!("Failed to read note: {}", err),
)
.into_response(),
}
}
pub async fn create_note() -> impl IntoResponse {
match filesystem::create_note() {
Ok(note) => (StatusCode::CREATED, Json::<Note>(note)).into_response(),
Err(err) => (
StatusCode::INTERNAL_SERVER_ERROR,
format!("Failed to create note: {}", err),
)
.into_response(),
}
}
async fn update_note(
Path(id): Path<String>,
body: String,
) -> impl IntoResponse {
match filesystem::update_note(&id, &body) {
Ok(note) => Json::<Note>(note).into_response(),
Err(err) if err.starts_with("Note not found") => {
(StatusCode::NOT_FOUND, err).into_response()
}
Err(err) => (
StatusCode::INTERNAL_SERVER_ERROR,
format!("Failed to update note: {}", err),
)
.into_response(),
}
}
async fn delete_note(Path(id): Path<String>) -> impl IntoResponse {
match filesystem::archive_note(&id) {
Ok(_) => StatusCode::NO_CONTENT.into_response(),
Err(err) if err.starts_with("Note not found") => {
(StatusCode::NOT_FOUND, err).into_response()
}
Err(err) => (
StatusCode::INTERNAL_SERVER_ERROR,
format!("Failed to archive note: {}", err),
)
.into_response(),
}
}

View File

@@ -0,0 +1,860 @@
use axum::{
extract::Path,
http::StatusCode,
response::IntoResponse,
routing::{get, put},
Json, Router,
};
use serde::{Deserialize, Serialize};
use std::fs;
use crate::routes::tasks::{
CreateTaskRequest, UpdateTaskMetaRequest,
list_project_tasks_handler, create_task_handler, get_task_handler,
update_task_content_handler, toggle_task_handler, update_task_meta_handler,
delete_task_handler,
};
use crate::services::filesystem;
use crate::config;
use crate::services::frontmatter;
#[derive(Debug, Serialize)]
pub struct Project {
pub id: String,
pub name: String,
pub path: String,
pub created: String,
}
#[derive(Debug, Serialize)]
pub struct ProjectWithContent {
pub id: String,
pub name: String,
pub path: String,
pub created: String,
pub content: String,
}
#[derive(Debug, Deserialize)]
pub struct UpdateProjectContentRequest {
pub content: String,
}
#[derive(Debug, Deserialize)]
pub struct CreateProjectRequest {
pub name: String,
}
#[derive(Debug, Serialize)]
pub struct ProjectNote {
pub id: String,
pub title: String,
pub path: String,
pub project_id: String,
pub created: String,
pub updated: String,
}
#[derive(Debug, Serialize)]
pub struct ProjectNoteWithContent {
pub id: String,
pub title: String,
pub path: String,
pub project_id: String,
pub created: String,
pub updated: String,
pub content: String,
}
#[derive(Debug, Deserialize)]
pub struct CreateNoteRequest {
pub title: Option<String>,
}
pub fn router() -> Router {
Router::new()
.route("/", get(list_projects).post(create_project))
.route("/{id}", get(get_project))
.route("/{id}/content", get(get_project_content).put(update_project_content))
// Task routes (file-based)
.route("/{id}/tasks", get(get_project_tasks).post(create_project_task))
.route("/{id}/tasks/{task_id}", get(get_project_task).put(update_project_task).delete(delete_project_task))
.route("/{id}/tasks/{task_id}/toggle", put(toggle_project_task))
.route("/{id}/tasks/{task_id}/meta", put(update_project_task_meta))
// Note routes
.route("/{id}/notes", get(list_project_notes).post(create_project_note))
.route("/{id}/notes/{note_id}", get(get_project_note).put(update_project_note).delete(delete_project_note))
}
// ============ Task Handlers ============
async fn get_project_tasks(Path(id): Path<String>) -> impl IntoResponse {
list_project_tasks_handler(id).await
}
async fn create_project_task(
Path(id): Path<String>,
Json(payload): Json<CreateTaskRequest>,
) -> impl IntoResponse {
create_task_handler(id, payload).await
}
async fn get_project_task(Path((id, task_id)): Path<(String, String)>) -> impl IntoResponse {
get_task_handler(id, task_id).await
}
async fn update_project_task(
Path((id, task_id)): Path<(String, String)>,
body: String,
) -> impl IntoResponse {
update_task_content_handler(id, task_id, body).await
}
async fn toggle_project_task(Path((id, task_id)): Path<(String, String)>) -> impl IntoResponse {
toggle_task_handler(id, task_id).await
}
async fn update_project_task_meta(
Path((id, task_id)): Path<(String, String)>,
Json(payload): Json<UpdateTaskMetaRequest>,
) -> impl IntoResponse {
update_task_meta_handler(id, task_id, payload).await
}
async fn delete_project_task(Path((id, task_id)): Path<(String, String)>) -> impl IntoResponse {
delete_task_handler(id, task_id).await
}
async fn list_projects() -> impl IntoResponse {
match list_projects_impl() {
Ok(projects) => Json(projects).into_response(),
Err(err) => (
StatusCode::INTERNAL_SERVER_ERROR,
format!("Failed to list projects: {}", err),
)
.into_response(),
}
}
fn list_projects_impl() -> Result<Vec<Project>, String> {
let projects_dir = config::data_dir().join("projects");
if !projects_dir.exists() {
return Ok(Vec::new());
}
let mut projects = Vec::new();
for entry in fs::read_dir(&projects_dir).map_err(|e| e.to_string())? {
let entry = entry.map_err(|e| e.to_string())?;
let path = entry.path();
if !path.is_dir() {
continue;
}
let index_path = path.join("index.md");
if !index_path.exists() {
continue;
}
let content = fs::read_to_string(&index_path).map_err(|e| e.to_string())?;
let (fm, _, _) = frontmatter::parse_frontmatter(&content);
let id = path
.file_name()
.and_then(|s| s.to_str())
.unwrap_or("")
.to_string();
let name = fm
.get(&serde_yaml::Value::from("title"))
.and_then(|v| v.as_str())
.map(String::from)
.unwrap_or_else(|| id.clone());
let created = fm
.get(&serde_yaml::Value::from("created"))
.and_then(|v| v.as_str())
.map(String::from)
.unwrap_or_default();
projects.push(Project {
id: id.clone(),
name,
path: format!("projects/{}", id),
created,
});
}
Ok(projects)
}
async fn get_project(Path(id): Path<String>) -> impl IntoResponse {
let projects_dir = config::data_dir().join("projects").join(&id);
let index_path = projects_dir.join("index.md");
if !index_path.exists() {
return (StatusCode::NOT_FOUND, "Project not found").into_response();
}
match fs::read_to_string(&index_path) {
Ok(content) => {
let (fm, _, _) = frontmatter::parse_frontmatter(&content);
let name = fm
.get(&serde_yaml::Value::from("title"))
.and_then(|v| v.as_str())
.map(String::from)
.unwrap_or_else(|| id.clone());
let created = fm
.get(&serde_yaml::Value::from("created"))
.and_then(|v| v.as_str())
.map(String::from)
.unwrap_or_default();
Json(Project {
id: id.clone(),
name,
path: format!("projects/{}", id),
created,
})
.into_response()
}
Err(err) => (
StatusCode::INTERNAL_SERVER_ERROR,
format!("Failed to read project: {}", err),
)
.into_response(),
}
}
async fn create_project(Json(payload): Json<CreateProjectRequest>) -> impl IntoResponse {
match create_project_impl(&payload.name) {
Ok(project) => (StatusCode::CREATED, Json(project)).into_response(),
Err(err) => (
StatusCode::INTERNAL_SERVER_ERROR,
format!("Failed to create project: {}", err),
)
.into_response(),
}
}
fn create_project_impl(name: &str) -> Result<Project, String> {
use chrono::Utc;
// Create slug from name
let slug = name
.to_lowercase()
.chars()
.map(|c| if c.is_alphanumeric() { c } else { '-' })
.collect::<String>()
.trim_matches('-')
.to_string();
if slug.is_empty() {
return Err("Invalid project name".to_string());
}
let projects_dir = config::data_dir().join("projects");
let project_dir = projects_dir.join(&slug);
if project_dir.exists() {
return Err("Project already exists".to_string());
}
// Create directories
fs::create_dir_all(&project_dir).map_err(|e| e.to_string())?;
fs::create_dir_all(project_dir.join("assets")).map_err(|e| e.to_string())?;
// Create index.md
let index_path = project_dir.join("index.md");
let now = Utc::now().to_rfc3339();
let mut fm = serde_yaml::Mapping::new();
fm.insert(
serde_yaml::Value::from("id"),
serde_yaml::Value::from(format!("{}-index", slug)),
);
fm.insert(
serde_yaml::Value::from("type"),
serde_yaml::Value::from("project"),
);
fm.insert(
serde_yaml::Value::from("title"),
serde_yaml::Value::from(name),
);
fm.insert(
serde_yaml::Value::from("created"),
serde_yaml::Value::from(now.clone()),
);
fm.insert(
serde_yaml::Value::from("updated"),
serde_yaml::Value::from(now.clone()),
);
let content = frontmatter::serialize_frontmatter(&fm, &format!("# {}\n\n", name))?;
filesystem::atomic_write(&index_path, content.as_bytes())?;
// Also create notes directory for project-scoped notes
fs::create_dir_all(project_dir.join("notes")).map_err(|e| e.to_string())?;
// Create tasks directory for file-based tasks
fs::create_dir_all(project_dir.join("tasks")).map_err(|e| e.to_string())?;
Ok(Project {
id: slug.clone(),
name: name.to_string(),
path: format!("projects/{}", slug),
created: now,
})
}
async fn get_project_content(Path(id): Path<String>) -> impl IntoResponse {
let index_path = config::data_dir()
.join("projects")
.join(&id)
.join("index.md");
if !index_path.exists() {
return (StatusCode::NOT_FOUND, "Project not found").into_response();
}
match fs::read_to_string(&index_path) {
Ok(content) => {
let (fm, body, _) = frontmatter::parse_frontmatter(&content);
let name = fm
.get(&serde_yaml::Value::from("title"))
.and_then(|v| v.as_str())
.map(String::from)
.unwrap_or_else(|| id.clone());
let created = fm
.get(&serde_yaml::Value::from("created"))
.and_then(|v| v.as_str())
.map(String::from)
.unwrap_or_default();
Json(ProjectWithContent {
id: id.clone(),
name,
path: format!("projects/{}", id),
created,
content: body,
})
.into_response()
}
Err(err) => (
StatusCode::INTERNAL_SERVER_ERROR,
format!("Failed to read project: {}", err),
)
.into_response(),
}
}
async fn update_project_content(
Path(id): Path<String>,
body: String,
) -> impl IntoResponse {
let index_path = config::data_dir()
.join("projects")
.join(&id)
.join("index.md");
if !index_path.exists() {
return (StatusCode::NOT_FOUND, "Project not found").into_response();
}
// Read existing file to get frontmatter
let existing = match fs::read_to_string(&index_path) {
Ok(content) => content,
Err(err) => {
return (
StatusCode::INTERNAL_SERVER_ERROR,
format!("Failed to read project: {}", err),
)
.into_response();
}
};
let (mut fm, _, _) = frontmatter::parse_frontmatter(&existing);
// Update the timestamp
let now = chrono::Utc::now().to_rfc3339();
fm.insert(
serde_yaml::Value::from("updated"),
serde_yaml::Value::from(now),
);
// Serialize with new content
let new_content = match frontmatter::serialize_frontmatter(&fm, &body) {
Ok(c) => c,
Err(err) => {
return (
StatusCode::INTERNAL_SERVER_ERROR,
format!("Failed to serialize: {}", err),
)
.into_response();
}
};
// Write back (atomic to prevent corruption)
if let Err(err) = filesystem::atomic_write(&index_path, new_content.as_bytes()) {
return (
StatusCode::INTERNAL_SERVER_ERROR,
format!("Failed to write file: {}", err),
)
.into_response();
}
let name = fm
.get(&serde_yaml::Value::from("title"))
.and_then(|v| v.as_str())
.map(String::from)
.unwrap_or_else(|| id.clone());
let created = fm
.get(&serde_yaml::Value::from("created"))
.and_then(|v| v.as_str())
.map(String::from)
.unwrap_or_default();
Json(ProjectWithContent {
id: id.clone(),
name,
path: format!("projects/{}", id),
created,
content: body,
})
.into_response()
}
// ============ Project Notes Handlers ============
async fn list_project_notes(Path(project_id): Path<String>) -> impl IntoResponse {
let notes_dir = config::data_dir()
.join("projects")
.join(&project_id)
.join("notes");
// Create notes directory if it doesn't exist
if !notes_dir.exists() {
if let Err(e) = fs::create_dir_all(&notes_dir) {
return (
StatusCode::INTERNAL_SERVER_ERROR,
format!("Failed to create notes directory: {}", e),
)
.into_response();
}
}
let mut notes = Vec::new();
let entries = match fs::read_dir(&notes_dir) {
Ok(e) => e,
Err(err) => {
return (
StatusCode::INTERNAL_SERVER_ERROR,
format!("Failed to read notes directory: {}", err),
)
.into_response();
}
};
for entry in entries {
let entry = match entry {
Ok(e) => e,
Err(_) => continue,
};
let path = entry.path();
if path.extension().and_then(|s| s.to_str()) != Some("md") {
continue;
}
let content = match fs::read_to_string(&path) {
Ok(c) => c,
Err(_) => continue,
};
let (fm, _, _) = frontmatter::parse_frontmatter(&content);
let filename = path
.file_stem()
.and_then(|s| s.to_str())
.unwrap_or("")
.to_string();
let id = fm
.get(&serde_yaml::Value::from("id"))
.and_then(|v| v.as_str())
.map(String::from)
.unwrap_or_else(|| filename.clone());
let title = fm
.get(&serde_yaml::Value::from("title"))
.and_then(|v| v.as_str())
.map(String::from)
.unwrap_or_else(|| filename.clone());
let created = fm
.get(&serde_yaml::Value::from("created"))
.and_then(|v| v.as_str())
.map(String::from)
.unwrap_or_default();
let updated = fm
.get(&serde_yaml::Value::from("updated"))
.and_then(|v| v.as_str())
.map(String::from)
.unwrap_or_default();
notes.push(ProjectNote {
id,
title,
path: format!("projects/{}/notes/{}.md", project_id, filename),
project_id: project_id.clone(),
created,
updated,
});
}
// Sort by updated date descending
// Sort by created date (stable ordering - won't change when note is viewed/edited)
notes.sort_by(|a, b| b.created.cmp(&a.created));
Json(notes).into_response()
}
async fn create_project_note(
Path(project_id): Path<String>,
Json(payload): Json<CreateNoteRequest>,
) -> impl IntoResponse {
use chrono::Utc;
let notes_dir = config::data_dir()
.join("projects")
.join(&project_id)
.join("notes");
// Create notes directory if it doesn't exist
if let Err(e) = fs::create_dir_all(&notes_dir) {
return (
StatusCode::INTERNAL_SERVER_ERROR,
format!("Failed to create notes directory: {}", e),
)
.into_response();
}
// Generate filename from timestamp
let now = Utc::now();
let filename = now.format("%Y%m%d-%H%M%S").to_string();
let note_path = notes_dir.join(format!("{}.md", filename));
let title = payload.title.unwrap_or_else(|| "Untitled".to_string());
let now_str = now.to_rfc3339();
let mut fm = serde_yaml::Mapping::new();
fm.insert(
serde_yaml::Value::from("id"),
serde_yaml::Value::from(format!("{}-{}", project_id, filename)),
);
fm.insert(
serde_yaml::Value::from("type"),
serde_yaml::Value::from("note"),
);
fm.insert(
serde_yaml::Value::from("title"),
serde_yaml::Value::from(title.clone()),
);
fm.insert(
serde_yaml::Value::from("project_id"),
serde_yaml::Value::from(project_id.clone()),
);
fm.insert(
serde_yaml::Value::from("created"),
serde_yaml::Value::from(now_str.clone()),
);
fm.insert(
serde_yaml::Value::from("updated"),
serde_yaml::Value::from(now_str.clone()),
);
let body = format!("# {}\n\n", title);
let content = match frontmatter::serialize_frontmatter(&fm, &body) {
Ok(c) => c,
Err(err) => {
return (
StatusCode::INTERNAL_SERVER_ERROR,
format!("Failed to serialize frontmatter: {}", err),
)
.into_response();
}
};
if let Err(err) = filesystem::atomic_write(&note_path, content.as_bytes()) {
return (
StatusCode::INTERNAL_SERVER_ERROR,
format!("Failed to write note file: {}", err),
)
.into_response();
}
(
StatusCode::CREATED,
Json(ProjectNoteWithContent {
id: format!("{}-{}", project_id, filename),
title,
path: format!("projects/{}/notes/{}.md", project_id, filename),
project_id,
created: now_str.clone(),
updated: now_str,
content: body,
}),
)
.into_response()
}
async fn get_project_note(Path((project_id, note_id)): Path<(String, String)>) -> impl IntoResponse {
let notes_dir = config::data_dir()
.join("projects")
.join(&project_id)
.join("notes");
// Try to find the note by ID (which might be the filename)
let note_path = notes_dir.join(format!("{}.md", note_id));
if !note_path.exists() {
// Try to find by searching all notes for matching ID
if let Ok(entries) = fs::read_dir(&notes_dir) {
for entry in entries.flatten() {
let path = entry.path();
if path.extension().and_then(|s| s.to_str()) != Some("md") {
continue;
}
if let Ok(content) = fs::read_to_string(&path) {
let (fm, body, _) = frontmatter::parse_frontmatter(&content);
let file_id = fm
.get(&serde_yaml::Value::from("id"))
.and_then(|v| v.as_str())
.map(String::from);
if file_id.as_deref() == Some(&note_id) {
let title = fm
.get(&serde_yaml::Value::from("title"))
.and_then(|v| v.as_str())
.map(String::from)
.unwrap_or_default();
let created = fm
.get(&serde_yaml::Value::from("created"))
.and_then(|v| v.as_str())
.map(String::from)
.unwrap_or_default();
let updated = fm
.get(&serde_yaml::Value::from("updated"))
.and_then(|v| v.as_str())
.map(String::from)
.unwrap_or_default();
let filename = path
.file_stem()
.and_then(|s| s.to_str())
.unwrap_or("")
.to_string();
return Json(ProjectNoteWithContent {
id: note_id,
title,
path: format!("projects/{}/notes/{}.md", project_id, filename),
project_id,
created,
updated,
content: body,
})
.into_response();
}
}
}
}
return (StatusCode::NOT_FOUND, "Note not found").into_response();
}
let content = match fs::read_to_string(&note_path) {
Ok(c) => c,
Err(err) => {
return (
StatusCode::INTERNAL_SERVER_ERROR,
format!("Failed to read note: {}", err),
)
.into_response();
}
};
let (fm, body, _) = frontmatter::parse_frontmatter(&content);
let id = fm
.get(&serde_yaml::Value::from("id"))
.and_then(|v| v.as_str())
.map(String::from)
.unwrap_or_else(|| note_id.clone());
let title = fm
.get(&serde_yaml::Value::from("title"))
.and_then(|v| v.as_str())
.map(String::from)
.unwrap_or_default();
let created = fm
.get(&serde_yaml::Value::from("created"))
.and_then(|v| v.as_str())
.map(String::from)
.unwrap_or_default();
let updated = fm
.get(&serde_yaml::Value::from("updated"))
.and_then(|v| v.as_str())
.map(String::from)
.unwrap_or_default();
Json(ProjectNoteWithContent {
id,
title,
path: format!("projects/{}/notes/{}.md", project_id, note_id),
project_id,
created,
updated,
content: body,
})
.into_response()
}
async fn update_project_note(
Path((project_id, note_id)): Path<(String, String)>,
body: String,
) -> impl IntoResponse {
let notes_dir = config::data_dir()
.join("projects")
.join(&project_id)
.join("notes");
let note_path = notes_dir.join(format!("{}.md", note_id));
if !note_path.exists() {
return (StatusCode::NOT_FOUND, "Note not found").into_response();
}
// Read existing content for frontmatter
let existing = match fs::read_to_string(&note_path) {
Ok(c) => c,
Err(err) => {
return (
StatusCode::INTERNAL_SERVER_ERROR,
format!("Failed to read note: {}", err),
)
.into_response();
}
};
let (mut fm, _, _) = frontmatter::parse_frontmatter(&existing);
// Update timestamp
let now = chrono::Utc::now().to_rfc3339();
fm.insert(
serde_yaml::Value::from("updated"),
serde_yaml::Value::from(now.clone()),
);
// Serialize with new content
let new_content = match frontmatter::serialize_frontmatter(&fm, &body) {
Ok(c) => c,
Err(err) => {
return (
StatusCode::INTERNAL_SERVER_ERROR,
format!("Failed to serialize: {}", err),
)
.into_response();
}
};
if let Err(err) = filesystem::atomic_write(&note_path, new_content.as_bytes()) {
return (
StatusCode::INTERNAL_SERVER_ERROR,
format!("Failed to write file: {}", err),
)
.into_response();
}
let id = fm
.get(&serde_yaml::Value::from("id"))
.and_then(|v| v.as_str())
.map(String::from)
.unwrap_or_else(|| note_id.clone());
let title = fm
.get(&serde_yaml::Value::from("title"))
.and_then(|v| v.as_str())
.map(String::from)
.unwrap_or_default();
let created = fm
.get(&serde_yaml::Value::from("created"))
.and_then(|v| v.as_str())
.map(String::from)
.unwrap_or_default();
Json(ProjectNoteWithContent {
id,
title,
path: format!("projects/{}/notes/{}.md", project_id, note_id),
project_id,
created,
updated: now,
content: body,
})
.into_response()
}
async fn delete_project_note(
Path((project_id, note_id)): Path<(String, String)>,
) -> impl IntoResponse {
let notes_dir = config::data_dir()
.join("projects")
.join(&project_id)
.join("notes");
let note_path = notes_dir.join(format!("{}.md", note_id));
if !note_path.exists() {
return (StatusCode::NOT_FOUND, "Note not found").into_response();
}
// Move to archive instead of deleting
let archive_dir = config::data_dir().join("archive");
if let Err(e) = fs::create_dir_all(&archive_dir) {
return (
StatusCode::INTERNAL_SERVER_ERROR,
format!("Failed to create archive directory: {}", e),
)
.into_response();
}
let archive_path = archive_dir.join(format!("{}-{}.md", project_id, note_id));
if let Err(err) = fs::rename(&note_path, &archive_path) {
return (
StatusCode::INTERNAL_SERVER_ERROR,
format!("Failed to archive note: {}", err),
)
.into_response();
}
StatusCode::NO_CONTENT.into_response()
}

View File

@@ -0,0 +1,30 @@
use axum::{
extract::Query,
http::StatusCode,
response::IntoResponse,
routing::get,
Json, Router,
};
use serde::Deserialize;
use crate::services::search;
#[derive(Debug, Deserialize)]
pub struct SearchQuery {
q: String,
}
pub fn router() -> Router {
Router::new().route("/", get(search_notes))
}
async fn search_notes(Query(params): Query<SearchQuery>) -> impl IntoResponse {
match search::search_notes(&params.q) {
Ok(results) => Json(results).into_response(),
Err(err) => (
StatusCode::INTERNAL_SERVER_ERROR,
format!("Search failed: {}", err),
)
.into_response(),
}
}

835
backend/src/routes/tasks.rs Normal file
View File

@@ -0,0 +1,835 @@
use axum::{
http::StatusCode,
response::IntoResponse,
routing::get,
Json, Router,
};
use serde::{Deserialize, Serialize};
use std::fs;
use std::path::Path as StdPath;
use crate::services::filesystem;
use crate::config;
use crate::services::frontmatter;
/// Task summary for list views
#[derive(Debug, Clone, Serialize)]
pub struct Task {
pub id: String,
pub title: String,
pub completed: bool,
pub section: String,
pub priority: Option<String>,
pub due_date: Option<String>,
pub is_active: bool,
pub tags: Vec<String>,
pub parent_id: Option<String>,
pub recurrence: Option<String>,
pub recurrence_interval: Option<u32>,
pub project_id: String,
pub path: String,
pub created: String,
pub updated: String,
}
/// Task with full content for detail view
#[derive(Debug, Clone, Serialize)]
pub struct TaskWithContent {
pub id: String,
pub title: String,
pub completed: bool,
pub section: String,
pub priority: Option<String>,
pub due_date: Option<String>,
pub is_active: bool,
pub tags: Vec<String>,
pub parent_id: Option<String>,
pub recurrence: Option<String>,
pub recurrence_interval: Option<u32>,
pub project_id: String,
pub path: String,
pub created: String,
pub updated: String,
pub content: String,
}
#[derive(Debug, Deserialize)]
pub struct CreateTaskRequest {
pub title: String,
pub section: Option<String>,
pub parent_id: Option<String>,
}
#[derive(Debug, Deserialize)]
pub struct UpdateTaskMetaRequest {
pub title: Option<String>,
pub section: Option<String>,
pub priority: Option<String>,
pub due_date: Option<String>,
pub is_active: Option<bool>,
pub tags: Option<Vec<String>>,
pub recurrence: Option<String>,
pub recurrence_interval: Option<u32>,
}
pub fn router() -> Router {
Router::new()
.route("/", get(list_all_tasks_handler))
}
// ============ Handler Functions (called from projects.rs) ============
/// List all tasks for a project
pub async fn list_project_tasks_handler(project_id: String) -> impl IntoResponse {
match list_project_tasks_impl(&project_id) {
Ok(tasks) => Json(tasks).into_response(),
Err(err) => (
StatusCode::INTERNAL_SERVER_ERROR,
format!("Failed to list tasks: {}", err),
)
.into_response(),
}
}
/// Create a new task
pub async fn create_task_handler(
project_id: String,
payload: CreateTaskRequest,
) -> impl IntoResponse {
match create_task_impl(&project_id, &payload.title, payload.section.as_deref(), payload.parent_id.as_deref()) {
Ok(task) => (StatusCode::CREATED, Json(task)).into_response(),
Err(err) => (
StatusCode::INTERNAL_SERVER_ERROR,
format!("Failed to create task: {}", err),
)
.into_response(),
}
}
/// Get a task with content
pub async fn get_task_handler(project_id: String, task_id: String) -> impl IntoResponse {
match get_task_impl(&project_id, &task_id) {
Ok(task) => Json(task).into_response(),
Err(err) if err.contains("not found") => {
(StatusCode::NOT_FOUND, err).into_response()
}
Err(err) => (
StatusCode::INTERNAL_SERVER_ERROR,
format!("Failed to get task: {}", err),
)
.into_response(),
}
}
/// Update task content (markdown body)
pub async fn update_task_content_handler(
project_id: String,
task_id: String,
body: String,
) -> impl IntoResponse {
match update_task_content_impl(&project_id, &task_id, &body) {
Ok(task) => Json(task).into_response(),
Err(err) if err.contains("not found") => {
(StatusCode::NOT_FOUND, err).into_response()
}
Err(err) => (
StatusCode::INTERNAL_SERVER_ERROR,
format!("Failed to update task: {}", err),
)
.into_response(),
}
}
/// Toggle task completion
pub async fn toggle_task_handler(project_id: String, task_id: String) -> impl IntoResponse {
match toggle_task_impl(&project_id, &task_id) {
Ok(task) => Json(task).into_response(),
Err(err) if err.contains("not found") => {
(StatusCode::NOT_FOUND, err).into_response()
}
Err(err) => (
StatusCode::INTERNAL_SERVER_ERROR,
format!("Failed to toggle task: {}", err),
)
.into_response(),
}
}
/// Update task metadata (title, section, priority)
pub async fn update_task_meta_handler(
project_id: String,
task_id: String,
payload: UpdateTaskMetaRequest,
) -> impl IntoResponse {
match update_task_meta_impl(&project_id, &task_id, payload) {
Ok(task) => Json(task).into_response(),
Err(err) if err.contains("not found") => {
(StatusCode::NOT_FOUND, err).into_response()
}
Err(err) => (
StatusCode::INTERNAL_SERVER_ERROR,
format!("Failed to update task metadata: {}", err),
)
.into_response(),
}
}
/// Delete (archive) a task
pub async fn delete_task_handler(project_id: String, task_id: String) -> impl IntoResponse {
match delete_task_impl(&project_id, &task_id) {
Ok(()) => StatusCode::NO_CONTENT.into_response(),
Err(err) if err.contains("not found") => {
(StatusCode::NOT_FOUND, err).into_response()
}
Err(err) => (
StatusCode::INTERNAL_SERVER_ERROR,
format!("Failed to delete task: {}", err),
)
.into_response(),
}
}
// ============ Implementation Functions ============
fn get_tasks_dir(project_id: &str) -> std::path::PathBuf {
config::data_dir()
.join("projects")
.join(project_id)
.join("tasks")
}
fn ensure_tasks_dir(project_id: &str) -> Result<std::path::PathBuf, String> {
let tasks_dir = get_tasks_dir(project_id);
if !tasks_dir.exists() {
fs::create_dir_all(&tasks_dir).map_err(|e| e.to_string())?;
}
Ok(tasks_dir)
}
fn list_project_tasks_impl(project_id: &str) -> Result<Vec<Task>, String> {
let tasks_dir = ensure_tasks_dir(project_id)?;
let mut tasks = Vec::new();
let entries = match fs::read_dir(&tasks_dir) {
Ok(e) => e,
Err(_) => return Ok(Vec::new()), // No tasks directory yet
};
for entry in entries {
let entry = match entry {
Ok(e) => e,
Err(_) => continue,
};
let path = entry.path();
if path.extension().and_then(|s| s.to_str()) != Some("md") {
continue;
}
let content = match fs::read_to_string(&path) {
Ok(c) => c,
Err(_) => continue,
};
if let Some(task) = parse_task_file(&content, &path, project_id) {
tasks.push(task);
}
}
// Sort by updated date descending (most recent first)
// Sort by created date (stable ordering - won't change when task is viewed/edited)
tasks.sort_by(|a, b| b.created.cmp(&a.created));
Ok(tasks)
}
/// Shared helper: extract common task fields from frontmatter.
/// Eliminates duplication between parse_task_file and parse_task_with_content.
fn extract_task_fields(fm: &serde_yaml::Mapping, path: &StdPath, project_id: &str) -> Task {
let filename = path
.file_stem()
.and_then(|s| s.to_str())
.unwrap_or("")
.to_string();
Task {
id: frontmatter::get_str_or(fm, "id", &filename),
title: frontmatter::get_str_or(fm, "title", "Untitled"),
completed: frontmatter::get_bool_or(fm, "completed", false),
section: frontmatter::get_str_or(fm, "section", "Active"),
priority: frontmatter::get_str(fm, "priority"),
due_date: frontmatter::get_str(fm, "due_date"),
is_active: frontmatter::get_bool_or(fm, "is_active", true),
tags: frontmatter::get_string_seq(fm, "tags"),
parent_id: frontmatter::get_str(fm, "parent_id"),
recurrence: frontmatter::get_str(fm, "recurrence"),
recurrence_interval: frontmatter::get_u64(fm, "recurrence_interval").map(|v| v as u32),
project_id: project_id.to_string(),
path: format!("projects/{}/tasks/{}.md", project_id, filename),
created: frontmatter::get_str_or(fm, "created", ""),
updated: frontmatter::get_str_or(fm, "updated", ""),
}
}
fn parse_task_file(content: &str, path: &StdPath, project_id: &str) -> Option<Task> {
let (fm, _, _) = frontmatter::parse_frontmatter(content);
Some(extract_task_fields(&fm, path, project_id))
}
fn create_task_impl(
project_id: &str,
title: &str,
section: Option<&str>,
parent_id: Option<&str>,
) -> Result<TaskWithContent, String> {
use chrono::Utc;
let tasks_dir = ensure_tasks_dir(project_id)?;
// Generate filename from timestamp
let now = Utc::now();
let filename = format!("task-{}", now.format("%Y%m%d-%H%M%S"));
let task_path = tasks_dir.join(format!("{}.md", filename));
let section = section.unwrap_or("Active").to_string();
let now_str = now.to_rfc3339();
let id = format!("{}-{}", project_id, filename);
let mut fm = serde_yaml::Mapping::new();
fm.insert(
serde_yaml::Value::from("id"),
serde_yaml::Value::from(id.clone()),
);
fm.insert(
serde_yaml::Value::from("type"),
serde_yaml::Value::from("task"),
);
fm.insert(
serde_yaml::Value::from("title"),
serde_yaml::Value::from(title),
);
fm.insert(
serde_yaml::Value::from("completed"),
serde_yaml::Value::from(false),
);
fm.insert(
serde_yaml::Value::from("section"),
serde_yaml::Value::from(section.clone()),
);
fm.insert(
serde_yaml::Value::from("priority"),
serde_yaml::Value::from("normal"),
);
fm.insert(
serde_yaml::Value::from("is_active"),
serde_yaml::Value::from(true),
);
fm.insert(
serde_yaml::Value::from("project_id"),
serde_yaml::Value::from(project_id),
);
if let Some(pid) = parent_id {
fm.insert(
serde_yaml::Value::from("parent_id"),
serde_yaml::Value::from(pid),
);
}
fm.insert(
serde_yaml::Value::from("created"),
serde_yaml::Value::from(now_str.clone()),
);
fm.insert(
serde_yaml::Value::from("updated"),
serde_yaml::Value::from(now_str.clone()),
);
let body = format!("# {}\n\n", title);
let content = frontmatter::serialize_frontmatter(&fm, &body)?;
filesystem::atomic_write(&task_path, content.as_bytes())?;
Ok(TaskWithContent {
id,
title: title.to_string(),
completed: false,
section,
priority: Some("normal".to_string()),
due_date: None,
is_active: true,
tags: Vec::new(),
parent_id: parent_id.map(String::from),
recurrence: None,
recurrence_interval: None,
project_id: project_id.to_string(),
path: format!("projects/{}/tasks/{}.md", project_id, filename),
created: now_str.clone(),
updated: now_str,
content: body,
})
}
fn get_task_impl(project_id: &str, task_id: &str) -> Result<TaskWithContent, String> {
let tasks_dir = get_tasks_dir(project_id);
// Try direct filename match first
let task_path = tasks_dir.join(format!("{}.md", task_id));
if task_path.exists() {
return read_task_with_content(&task_path, project_id);
}
// Search by ID in frontmatter
if let Ok(entries) = fs::read_dir(&tasks_dir) {
for entry in entries.flatten() {
let path = entry.path();
if path.extension().and_then(|s| s.to_str()) != Some("md") {
continue;
}
if let Ok(content) = fs::read_to_string(&path) {
let (fm, body, _) = frontmatter::parse_frontmatter(&content);
let file_id = fm
.get(&serde_yaml::Value::from("id"))
.and_then(|v| v.as_str())
.map(String::from);
if file_id.as_deref() == Some(task_id) {
return parse_task_with_content(&fm, &body, &path, project_id);
}
}
}
}
Err("Task not found".to_string())
}
fn read_task_with_content(path: &StdPath, project_id: &str) -> Result<TaskWithContent, String> {
let content = fs::read_to_string(path).map_err(|e| e.to_string())?;
let (fm, body, _) = frontmatter::parse_frontmatter(&content);
parse_task_with_content(&fm, &body, path, project_id)
}
fn parse_task_with_content(
fm: &serde_yaml::Mapping,
body: &str,
path: &StdPath,
project_id: &str,
) -> Result<TaskWithContent, String> {
let task = extract_task_fields(fm, path, project_id);
Ok(TaskWithContent {
id: task.id,
title: task.title,
completed: task.completed,
section: task.section,
priority: task.priority,
due_date: task.due_date,
is_active: task.is_active,
tags: task.tags,
parent_id: task.parent_id,
recurrence: task.recurrence,
recurrence_interval: task.recurrence_interval,
project_id: task.project_id,
path: task.path,
created: task.created,
updated: task.updated,
content: body.to_string(),
})
}
fn update_task_content_impl(
project_id: &str,
task_id: &str,
new_body: &str,
) -> Result<TaskWithContent, String> {
let task_path = find_task_path(project_id, task_id)?;
// Read existing content
let existing = fs::read_to_string(&task_path).map_err(|e| e.to_string())?;
let (mut fm, _, _) = frontmatter::parse_frontmatter(&existing);
// Update timestamp
let now = chrono::Utc::now().to_rfc3339();
fm.insert(
serde_yaml::Value::from("updated"),
serde_yaml::Value::from(now),
);
// Serialize with new content (atomic write to prevent corruption)
let new_content = frontmatter::serialize_frontmatter(&fm, new_body)?;
filesystem::atomic_write(&task_path, new_content.as_bytes())?;
parse_task_with_content(&fm, new_body, &task_path, project_id)
}
fn toggle_task_impl(project_id: &str, task_id: &str) -> Result<Task, String> {
let task_path = find_task_path(project_id, task_id)?;
// Read existing content
let existing = fs::read_to_string(&task_path).map_err(|e| e.to_string())?;
let (mut fm, body, _) = frontmatter::parse_frontmatter(&existing);
// Toggle completed
let current_completed = fm
.get(&serde_yaml::Value::from("completed"))
.and_then(|v| v.as_bool())
.unwrap_or(false);
let new_completed = !current_completed;
fm.insert(
serde_yaml::Value::from("completed"),
serde_yaml::Value::from(new_completed),
);
// Update section based on completion status
let new_section = if new_completed {
"Completed"
} else {
"Active"
};
fm.insert(
serde_yaml::Value::from("section"),
serde_yaml::Value::from(new_section),
);
// Update timestamp
let now = chrono::Utc::now().to_rfc3339();
fm.insert(
serde_yaml::Value::from("updated"),
serde_yaml::Value::from(now),
);
// Serialize and write (atomic to prevent corruption)
let new_content = frontmatter::serialize_frontmatter(&fm, &body)?;
filesystem::atomic_write(&task_path, new_content.as_bytes())?;
// If completing a recurring task, create the next instance
if new_completed {
let recurrence = fm
.get(&serde_yaml::Value::from("recurrence"))
.and_then(|v| v.as_str())
.map(String::from);
if let Some(rec) = recurrence {
let interval = fm
.get(&serde_yaml::Value::from("recurrence_interval"))
.and_then(|v| v.as_u64())
.unwrap_or(1) as i64;
let title = fm
.get(&serde_yaml::Value::from("title"))
.and_then(|v| v.as_str())
.unwrap_or("Untitled")
.to_string();
let due_date = fm
.get(&serde_yaml::Value::from("due_date"))
.and_then(|v| v.as_str())
.map(String::from);
let tags = fm
.get(&serde_yaml::Value::from("tags"))
.and_then(|v| v.as_sequence())
.map(|seq| {
seq.iter()
.filter_map(|v| v.as_str().map(String::from))
.collect::<Vec<_>>()
})
.unwrap_or_default();
// Calculate next due date
let next_due = calculate_next_due_date(due_date.as_deref(), &rec, interval);
// Create the next recurring task
let _ = create_recurring_task_impl(
project_id,
&title,
next_due.as_deref(),
&rec,
interval as u32,
&tags,
);
}
}
// Return updated task
let task = parse_task_file(&fs::read_to_string(&task_path).unwrap(), &task_path, project_id)
.ok_or_else(|| "Failed to parse updated task".to_string())?;
Ok(task)
}
fn calculate_next_due_date(current_due: Option<&str>, recurrence: &str, interval: i64) -> Option<String> {
use chrono::{NaiveDate, Duration, Utc, Months};
let base_date = if let Some(due_str) = current_due {
NaiveDate::parse_from_str(due_str, "%Y-%m-%d").unwrap_or_else(|_| Utc::now().date_naive())
} else {
Utc::now().date_naive()
};
let next = match recurrence {
"daily" => Some(base_date + Duration::days(interval)),
"weekly" => Some(base_date + Duration::weeks(interval)),
"monthly" => base_date.checked_add_months(Months::new(interval as u32)),
"yearly" => base_date.checked_add_months(Months::new((interval * 12) as u32)),
_ => None,
};
next.map(|d| d.format("%Y-%m-%d").to_string())
}
fn create_recurring_task_impl(
project_id: &str,
title: &str,
due_date: Option<&str>,
recurrence: &str,
interval: u32,
tags: &[String],
) -> Result<TaskWithContent, String> {
use chrono::Utc;
let tasks_dir = ensure_tasks_dir(project_id)?;
let now = Utc::now();
// Add a small suffix to avoid filename collision with completed task
let filename = format!("task-{}-r", now.format("%Y%m%d-%H%M%S"));
let task_path = tasks_dir.join(format!("{}.md", filename));
let now_str = now.to_rfc3339();
let id = format!("{}-{}", project_id, filename);
let mut fm = serde_yaml::Mapping::new();
fm.insert(serde_yaml::Value::from("id"), serde_yaml::Value::from(id.clone()));
fm.insert(serde_yaml::Value::from("type"), serde_yaml::Value::from("task"));
fm.insert(serde_yaml::Value::from("title"), serde_yaml::Value::from(title));
fm.insert(serde_yaml::Value::from("completed"), serde_yaml::Value::from(false));
fm.insert(serde_yaml::Value::from("section"), serde_yaml::Value::from("Active"));
fm.insert(serde_yaml::Value::from("priority"), serde_yaml::Value::from("normal"));
fm.insert(serde_yaml::Value::from("is_active"), serde_yaml::Value::from(true));
fm.insert(serde_yaml::Value::from("project_id"), serde_yaml::Value::from(project_id));
fm.insert(serde_yaml::Value::from("recurrence"), serde_yaml::Value::from(recurrence));
fm.insert(serde_yaml::Value::from("recurrence_interval"), serde_yaml::Value::from(interval as u64));
if let Some(due) = due_date {
fm.insert(serde_yaml::Value::from("due_date"), serde_yaml::Value::from(due));
}
if !tags.is_empty() {
let yaml_tags: Vec<serde_yaml::Value> = tags.iter().map(|t| serde_yaml::Value::from(t.as_str())).collect();
fm.insert(serde_yaml::Value::from("tags"), serde_yaml::Value::Sequence(yaml_tags));
}
fm.insert(serde_yaml::Value::from("created"), serde_yaml::Value::from(now_str.clone()));
fm.insert(serde_yaml::Value::from("updated"), serde_yaml::Value::from(now_str.clone()));
let body = format!("# {}\n\n", title);
let content = frontmatter::serialize_frontmatter(&fm, &body)?;
filesystem::atomic_write(&task_path, content.as_bytes())?;
Ok(TaskWithContent {
id,
title: title.to_string(),
completed: false,
section: "Active".to_string(),
priority: Some("normal".to_string()),
due_date: due_date.map(String::from),
is_active: true,
tags: tags.to_vec(),
parent_id: None,
recurrence: Some(recurrence.to_string()),
recurrence_interval: Some(interval),
project_id: project_id.to_string(),
path: format!("projects/{}/tasks/{}.md", project_id, filename),
created: now_str.clone(),
updated: now_str,
content: body,
})
}
fn update_task_meta_impl(
project_id: &str,
task_id: &str,
meta: UpdateTaskMetaRequest,
) -> Result<Task, String> {
let task_path = find_task_path(project_id, task_id)?;
// Read existing content
let existing = fs::read_to_string(&task_path).map_err(|e| e.to_string())?;
let (mut fm, body, _) = frontmatter::parse_frontmatter(&existing);
// Update fields if provided
if let Some(title) = meta.title {
fm.insert(
serde_yaml::Value::from("title"),
serde_yaml::Value::from(title),
);
}
if let Some(section) = meta.section {
fm.insert(
serde_yaml::Value::from("section"),
serde_yaml::Value::from(section),
);
}
if let Some(priority) = meta.priority {
fm.insert(
serde_yaml::Value::from("priority"),
serde_yaml::Value::from(priority),
);
}
if let Some(due_date) = meta.due_date {
fm.insert(
serde_yaml::Value::from("due_date"),
serde_yaml::Value::from(due_date),
);
}
if let Some(is_active) = meta.is_active {
fm.insert(
serde_yaml::Value::from("is_active"),
serde_yaml::Value::from(is_active),
);
}
if let Some(tags) = meta.tags {
let yaml_tags: Vec<serde_yaml::Value> =
tags.into_iter().map(serde_yaml::Value::from).collect();
fm.insert(
serde_yaml::Value::from("tags"),
serde_yaml::Value::Sequence(yaml_tags),
);
}
if let Some(recurrence) = meta.recurrence {
if recurrence.is_empty() {
fm.remove(&serde_yaml::Value::from("recurrence"));
fm.remove(&serde_yaml::Value::from("recurrence_interval"));
} else {
fm.insert(
serde_yaml::Value::from("recurrence"),
serde_yaml::Value::from(recurrence),
);
}
}
if let Some(interval) = meta.recurrence_interval {
fm.insert(
serde_yaml::Value::from("recurrence_interval"),
serde_yaml::Value::from(interval as u64),
);
}
// Update timestamp
let now = chrono::Utc::now().to_rfc3339();
fm.insert(
serde_yaml::Value::from("updated"),
serde_yaml::Value::from(now),
);
// Serialize and write (atomic to prevent corruption)
let new_content = frontmatter::serialize_frontmatter(&fm, &body)?;
filesystem::atomic_write(&task_path, new_content.as_bytes())?;
// Return updated task
let task = parse_task_file(&fs::read_to_string(&task_path).unwrap(), &task_path, project_id)
.ok_or_else(|| "Failed to parse updated task".to_string())?;
Ok(task)
}
fn delete_task_impl(project_id: &str, task_id: &str) -> Result<(), String> {
let task_path = find_task_path(project_id, task_id)?;
// Move to archive
let archive_dir = config::data_dir().join("archive");
fs::create_dir_all(&archive_dir).map_err(|e| e.to_string())?;
let filename = task_path
.file_name()
.and_then(|s| s.to_str())
.unwrap_or("task.md");
let archive_path = archive_dir.join(format!("{}-{}", project_id, filename));
fs::rename(&task_path, &archive_path).map_err(|e| e.to_string())?;
Ok(())
}
fn find_task_path(project_id: &str, task_id: &str) -> Result<std::path::PathBuf, String> {
let tasks_dir = get_tasks_dir(project_id);
// Try direct filename match
let direct_path = tasks_dir.join(format!("{}.md", task_id));
if direct_path.exists() {
return Ok(direct_path);
}
// Search by ID in frontmatter
if let Ok(entries) = fs::read_dir(&tasks_dir) {
for entry in entries.flatten() {
let path = entry.path();
if path.extension().and_then(|s| s.to_str()) != Some("md") {
continue;
}
if let Ok(content) = fs::read_to_string(&path) {
let (fm, _, _) = frontmatter::parse_frontmatter(&content);
let file_id = fm
.get(&serde_yaml::Value::from("id"))
.and_then(|v| v.as_str());
if file_id == Some(task_id) {
return Ok(path);
}
}
}
}
Err("Task not found".to_string())
}
// ============ Legacy/Global Task Listing ============
async fn list_all_tasks_handler() -> impl IntoResponse {
match list_all_tasks_impl() {
Ok(tasks) => Json(tasks).into_response(),
Err(err) => (
StatusCode::INTERNAL_SERVER_ERROR,
format!("Failed to list tasks: {}", err),
)
.into_response(),
}
}
fn list_all_tasks_impl() -> Result<Vec<Task>, String> {
let projects_dir = config::data_dir().join("projects");
if !projects_dir.exists() {
return Ok(Vec::new());
}
let mut all_tasks = Vec::new();
for entry in fs::read_dir(&projects_dir).map_err(|e| e.to_string())? {
let entry = entry.map_err(|e| e.to_string())?;
let project_path = entry.path();
if !project_path.is_dir() {
continue;
}
let project_id = project_path
.file_name()
.and_then(|s| s.to_str())
.unwrap_or("")
.to_string();
if let Ok(tasks) = list_project_tasks_impl(&project_id) {
all_tasks.extend(tasks);
}
}
// Sort all tasks by updated date descending
// Sort by created date (stable ordering)
all_tasks.sort_by(|a, b| b.created.cmp(&a.created));
Ok(all_tasks)
}

View File

@@ -0,0 +1,349 @@
use std::fs;
use std::io::Write;
use std::path::Path;
use serde_yaml::Value;
use walkdir::WalkDir;
use crate::models::note::{Note, NoteSummary};
use crate::services::frontmatter;
use crate::config;
/// List all notes in the filesystem (read-only).
pub fn list_notes() -> Result<Vec<NoteSummary>, String> {
let mut notes = Vec::new();
let root = config::data_dir();
for entry in WalkDir::new(root)
.into_iter()
.filter_entry(|e| !is_ignored(e.path()))
.filter_map(Result::ok)
{
let path = entry.path();
if path.extension().and_then(|s| s.to_str()) != Some("md") {
continue;
}
// Only include notes and project index files
if !is_note_file(path) {
continue;
}
match parse_note_summary(path) {
Ok(note) => notes.push(note),
Err(err) => {
tracing::warn!("Skipping file {:?}: {}", path, err);
}
}
}
Ok(notes)
}
fn is_ignored(path: &Path) -> bool {
path.components().any(|c| {
matches!(
c.as_os_str().to_str(),
Some(".git") | Some("assets") | Some("archive")
)
})
}
fn is_note_file(path: &Path) -> bool {
let path_str = path.to_string_lossy();
// data/notes/**/*.md (handles both forward and back slashes)
if path_str.contains("notes") && !path_str.contains("archive") {
return true;
}
// data/projects/*/index.md
if path_str.contains("projects") && path.file_name().and_then(|s| s.to_str()) == Some("index.md") {
return true;
}
// Root-level files (index.md, inbox.md) - parent is the data dir
if let Some(parent) = path.parent() {
if parent == config::data_dir() {
return true;
}
}
false
}
fn parse_note_summary(path: &Path) -> Result<NoteSummary, String> {
let content = fs::read_to_string(path).map_err(|e| e.to_string())?;
let (fm, _body, _has_fm) = frontmatter::parse_frontmatter(&content);
let id = fm
.get(&Value::from("id"))
.and_then(|v| v.as_str())
.map(String::from)
.unwrap_or_else(|| frontmatter::derive_id_from_path(path));
let title = fm
.get(&Value::from("title"))
.and_then(|v| v.as_str())
.map(String::from)
.unwrap_or_else(|| {
path.file_stem()
.and_then(|s| s.to_str())
.unwrap_or("Untitled")
.to_string()
});
let note_type = fm
.get(&Value::from("type"))
.and_then(|v| v.as_str())
.unwrap_or("note")
.to_string();
let updated = fm
.get(&Value::from("updated"))
.and_then(|v| v.as_str())
.map(String::from);
Ok(NoteSummary {
id,
title,
path: normalize_path(path),
note_type,
updated,
})
}
pub fn normalize_path(path: &Path) -> String {
// Strip the data directory prefix and normalize separators
let path_str = path.to_string_lossy();
let stripped = if let Some(idx) = path_str.find("data") {
&path_str[idx + 5..] // Skip "data" + separator
} else {
&path_str
};
stripped.replace('\\', "/").trim_start_matches('/').to_string()
}
/// Read a full note by deterministic ID.
pub fn read_note_by_id(note_id: &str) -> Result<Note, String> {
let root = config::data_dir();
for entry in WalkDir::new(root)
.into_iter()
.filter_entry(|e| !is_ignored(e.path()))
.filter_map(Result::ok)
{
let path = entry.path();
if path.extension().and_then(|s| s.to_str()) != Some("md") {
continue;
}
if !is_note_file(path) {
continue;
}
let content = fs::read_to_string(path).map_err(|e| e.to_string())?;
let (fm, body, _has_fm) = frontmatter::parse_frontmatter(&content);
let derived_id = fm
.get(&Value::from("id"))
.and_then(|v| v.as_str())
.map(String::from)
.unwrap_or_else(|| frontmatter::derive_id_from_path(path));
if derived_id != note_id {
continue;
}
let note_type = fm
.get(&Value::from("type"))
.and_then(|v| v.as_str())
.unwrap_or("note")
.to_string();
return Ok(Note {
id: derived_id,
path: normalize_path(path),
note_type,
frontmatter: fm,
content: body.trim_start().to_string(),
});
}
Err(format!("Note not found: {}", note_id))
}
/// Create a new empty note in data/notes/.
pub fn create_note() -> Result<Note, String> {
use chrono::Utc;
let dir = config::data_dir().join("notes");
fs::create_dir_all(&dir).map_err(|e| e.to_string())?;
let filename = format!("{}.md", Utc::now().format("%Y%m%d-%H%M%S"));
let path = dir.join(&filename);
let fm = frontmatter::generate_frontmatter(&path, "note");
let content = frontmatter::serialize_frontmatter(&fm, "")?;
// Atomic write: write to temp file, then rename
atomic_write(&path, content.as_bytes())?;
let id = fm
.get(&Value::from("id"))
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
Ok(Note {
id,
path: normalize_path(&path),
note_type: "note".to_string(),
frontmatter: fm,
content: String::new(),
})
}
/// Update an existing note by ID with full markdown payload.
/// Handles notes with or without existing frontmatter.
/// Preserves user-defined fields, updates backend-owned fields.
pub fn update_note(note_id: &str, new_content: &str) -> Result<Note, String> {
let root = config::data_dir();
for entry in WalkDir::new(root)
.into_iter()
.filter_entry(|e| !is_ignored(e.path()))
.filter_map(Result::ok)
{
let path = entry.path();
if path.extension().and_then(|s| s.to_str()) != Some("md") {
continue;
}
if !is_note_file(path) {
continue;
}
let content = fs::read_to_string(path).map_err(|e| e.to_string())?;
let (mut fm, _old_body, has_fm) = frontmatter::parse_frontmatter(&content);
let derived_id = fm
.get(&Value::from("id"))
.and_then(|v| v.as_str())
.map(String::from)
.unwrap_or_else(|| frontmatter::derive_id_from_path(path));
if derived_id != note_id {
continue;
}
// Ensure frontmatter has all required fields
// This handles files without frontmatter or with incomplete frontmatter
if !has_fm || !frontmatter::is_frontmatter_complete(&fm) {
frontmatter::ensure_frontmatter(&mut fm, path);
} else {
// Just update the timestamp
frontmatter::update_frontmatter(&mut fm);
}
// Rebuild file content
let rebuilt = frontmatter::serialize_frontmatter(&fm, new_content.trim_start())?;
// Atomic write
atomic_write(path, rebuilt.as_bytes())?;
let note_type = fm
.get(&Value::from("type"))
.and_then(|v| v.as_str())
.unwrap_or("note")
.to_string();
return Ok(Note {
id: derived_id,
path: normalize_path(path),
note_type,
frontmatter: fm,
content: new_content.to_string(),
});
}
Err(format!("Note not found: {}", note_id))
}
/// Archive a note by ID (move to data/archive/).
pub fn archive_note(note_id: &str) -> Result<(), String> {
let root = config::data_dir();
let archive_dir = config::data_dir().join("archive");
fs::create_dir_all(&archive_dir).map_err(|e| e.to_string())?;
for entry in WalkDir::new(root)
.into_iter()
.filter_entry(|e| !is_ignored(e.path()))
.filter_map(Result::ok)
{
let path = entry.path();
if path.extension().and_then(|s| s.to_str()) != Some("md") {
continue;
}
if !is_note_file(path) {
continue;
}
let content = fs::read_to_string(path).map_err(|e| e.to_string())?;
let (fm, _, _) = frontmatter::parse_frontmatter(&content);
let derived_id = fm
.get(&Value::from("id"))
.and_then(|v| v.as_str())
.map(String::from)
.unwrap_or_else(|| frontmatter::derive_id_from_path(path));
if derived_id != note_id {
continue;
}
let filename = path.file_name().ok_or("Invalid filename")?;
let target = archive_dir.join(filename);
fs::rename(path, target).map_err(|e| e.to_string())?;
return Ok(());
}
Err(format!("Note not found: {}", note_id))
}
/// Atomic write: write to temp file, then rename.
/// This prevents data loss on crash or power failure.
/// Also marks the file as recently saved to avoid triggering external edit notifications.
pub fn atomic_write(path: &Path, contents: &[u8]) -> Result<(), String> {
let parent = path.parent().ok_or("Invalid path")?;
let temp_name = format!(
".{}.tmp",
path.file_name()
.and_then(|s| s.to_str())
.unwrap_or("file")
);
let temp_path = parent.join(temp_name);
// Mark this file as being saved by us (to avoid triggering external edit notification)
let normalized = normalize_path(path);
crate::watcher::mark_file_saved(&normalized);
// Write to temp file
let mut file = fs::File::create(&temp_path).map_err(|e| e.to_string())?;
file.write_all(contents).map_err(|e| e.to_string())?;
file.sync_all().map_err(|e| e.to_string())?;
drop(file);
// Rename temp file to target (atomic on most filesystems)
fs::rename(&temp_path, path).map_err(|e| e.to_string())?;
Ok(())
}

View File

@@ -0,0 +1,191 @@
use std::path::Path;
use chrono::Utc;
use serde_yaml::{Mapping, Value};
/// Derive deterministic ID from file path.
/// Matches filesystem ID logic: strips data directory prefix and folder name.
pub fn derive_id_from_path(path: &Path) -> String {
let path_str = path.to_string_lossy();
// Find "data" in the path and strip everything before and including it
let rel_str = if let Some(idx) = path_str.find("data") {
&path_str[idx + 5..] // Skip "data" + separator
} else {
&path_str
};
// Split by both forward and back slashes, filter empty parts
let mut parts: Vec<String> = rel_str
.split(['/', '\\'])
.filter(|s| !s.is_empty())
.map(|s| s.replace(".md", ""))
.collect();
// Drop top-level folder name (notes, projects, etc.) if we have multiple parts
if parts.len() > 1 {
parts.remove(0);
}
parts.join("-")
}
/// Parse frontmatter from file content.
/// Returns (frontmatter mapping, body content, has_frontmatter flag).
pub fn parse_frontmatter(content: &str) -> (Mapping, String, bool) {
if !content.starts_with("---") {
return (Mapping::new(), content.to_string(), false);
}
let mut parts = content.splitn(3, "---");
parts.next(); // empty before first ---
let yaml = parts.next().unwrap_or("");
let body = parts.next().unwrap_or("");
let fm: Value = serde_yaml::from_str(yaml).unwrap_or(Value::Null);
let map = fm.as_mapping().cloned().unwrap_or_default();
(map, body.to_string(), true)
}
/// Serialize frontmatter and body back to markdown string.
pub fn serialize_frontmatter(frontmatter: &Mapping, body: &str) -> Result<String, String> {
let yaml = serde_yaml::to_string(frontmatter).map_err(|e| e.to_string())?;
let mut content = String::new();
content.push_str("---\n");
content.push_str(&yaml);
content.push_str("---\n\n");
content.push_str(body.trim_start());
Ok(content)
}
/// Generate initial frontmatter for a newly created file.
/// Sets backend-owned fields only.
pub fn generate_frontmatter(path: &Path, note_type: &str) -> Mapping {
let mut map = Mapping::new();
let id = derive_id_from_path(path);
let now = Utc::now().to_rfc3339();
map.insert(Value::from("id"), Value::from(id));
map.insert(Value::from("type"), Value::from(note_type));
map.insert(Value::from("created"), Value::from(now.clone()));
map.insert(Value::from("updated"), Value::from(now));
map
}
/// Ensure frontmatter has all required backend-owned fields.
/// - If `id` is missing, derive from path
/// - If `created` is missing, set to now
/// - Always updates `updated` timestamp
/// - Preserves all user-defined fields (title, tags, status, etc.)
pub fn ensure_frontmatter(existing: &mut Mapping, path: &Path) {
let now = Utc::now().to_rfc3339();
// Ensure ID exists (derive from path if missing)
if !existing.contains_key(&Value::from("id")) {
let id = derive_id_from_path(path);
existing.insert(Value::from("id"), Value::from(id));
}
// Ensure created timestamp exists (set once, never overwritten)
if !existing.contains_key(&Value::from("created")) {
existing.insert(Value::from("created"), Value::from(now.clone()));
}
// Always update the updated timestamp
existing.insert(Value::from("updated"), Value::from(now));
}
/// Update frontmatter on save.
/// Only updates `updated` timestamp, preserves all other fields.
pub fn update_frontmatter(existing: &mut Mapping) {
let now = Utc::now().to_rfc3339();
existing.insert(Value::from("updated"), Value::from(now));
}
/// Check if frontmatter has all required backend-owned fields.
pub fn is_frontmatter_complete(frontmatter: &Mapping) -> bool {
frontmatter.contains_key(&Value::from("id"))
&& frontmatter.contains_key(&Value::from("created"))
&& frontmatter.contains_key(&Value::from("updated"))
}
// ============ Helper functions for cleaner frontmatter field access ============
/// Get a string value from frontmatter by key.
pub fn get_str(fm: &Mapping, key: &str) -> Option<String> {
fm.get(&Value::from(key))
.and_then(|v| v.as_str())
.map(String::from)
}
/// Get a string value from frontmatter, with a default fallback.
pub fn get_str_or(fm: &Mapping, key: &str, default: &str) -> String {
get_str(fm, key).unwrap_or_else(|| default.to_string())
}
/// Get a bool value from frontmatter by key.
pub fn get_bool(fm: &Mapping, key: &str) -> Option<bool> {
fm.get(&Value::from(key)).and_then(|v| v.as_bool())
}
/// Get a bool value from frontmatter, with a default fallback.
pub fn get_bool_or(fm: &Mapping, key: &str, default: bool) -> bool {
get_bool(fm, key).unwrap_or(default)
}
/// Get a u64 value from frontmatter by key.
pub fn get_u64(fm: &Mapping, key: &str) -> Option<u64> {
fm.get(&Value::from(key)).and_then(|v| v.as_u64())
}
/// Get a string sequence (tags, etc.) from frontmatter by key.
pub fn get_string_seq(fm: &Mapping, key: &str) -> Vec<String> {
fm.get(&Value::from(key))
.and_then(|v| v.as_sequence())
.map(|seq| {
seq.iter()
.filter_map(|v| v.as_str().map(String::from))
.collect()
})
.unwrap_or_default()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_frontmatter_with_frontmatter() {
let content = "---\nid: test\ntitle: Test Note\n---\n\nBody content";
let (fm, body, has_fm) = parse_frontmatter(content);
assert!(has_fm);
assert_eq!(fm.get(&Value::from("id")).unwrap().as_str().unwrap(), "test");
assert_eq!(fm.get(&Value::from("title")).unwrap().as_str().unwrap(), "Test Note");
assert!(body.contains("Body content"));
}
#[test]
fn test_parse_frontmatter_without_frontmatter() {
let content = "Just some content without frontmatter";
let (fm, body, has_fm) = parse_frontmatter(content);
assert!(!has_fm);
assert!(fm.is_empty());
assert_eq!(body, content);
}
#[test]
fn test_derive_id_from_path() {
let path = Path::new("data/notes/my-note.md");
assert_eq!(derive_id_from_path(path), "my-note");
let path = Path::new("data/projects/myproject/index.md");
assert_eq!(derive_id_from_path(path), "myproject-index");
}
}

655
backend/src/services/git.rs Normal file
View File

@@ -0,0 +1,655 @@
use std::time::Duration;
use chrono::Utc;
use git2::{Repository, Signature, StatusOptions};
use serde::Serialize;
use tokio::time::interval;
use crate::config;
/// Git status for a file
#[derive(Debug, Clone, Serialize)]
pub struct FileStatus {
pub path: String,
pub status: String, // "new", "modified", "deleted", "renamed", "untracked"
}
/// Overall repository status
#[derive(Debug, Serialize)]
pub struct RepoStatus {
pub is_repo: bool,
pub branch: Option<String>,
pub files: Vec<FileStatus>,
pub has_changes: bool,
pub last_commit: Option<CommitInfo>,
}
/// Commit information
#[derive(Debug, Clone, Serialize)]
pub struct CommitInfo {
pub id: String,
pub message: String,
pub timestamp: String,
}
/// Extended commit info for history
#[derive(Debug, Serialize)]
pub struct CommitDetail {
pub id: String,
pub short_id: String,
pub message: String,
pub author: String,
pub timestamp: String,
pub files_changed: usize,
}
/// Diff information
#[derive(Debug, Serialize)]
pub struct DiffInfo {
pub files: Vec<FileDiff>,
pub stats: DiffStats,
}
/// File diff
#[derive(Debug, Serialize)]
pub struct FileDiff {
pub path: String,
pub status: String,
pub additions: usize,
pub deletions: usize,
pub hunks: Vec<DiffHunk>,
}
/// Diff hunk (section of changes)
#[derive(Debug, Serialize)]
pub struct DiffHunk {
pub header: String,
pub lines: Vec<DiffLine>,
}
/// Single diff line
#[derive(Debug, Serialize)]
pub struct DiffLine {
pub origin: char,
pub content: String,
}
/// Diff statistics
#[derive(Debug, Serialize)]
pub struct DiffStats {
pub files_changed: usize,
pub insertions: usize,
pub deletions: usize,
}
/// Remote repository information
#[derive(Debug, Serialize)]
pub struct RemoteInfo {
pub name: String,
pub url: String,
pub has_upstream: bool,
pub ahead: usize,
pub behind: usize,
}
/// Auto-commit is enabled by default.
/// The background task simply tries to commit every interval;
/// commit_all() already handles "no changes" gracefully.
/// Get repository status
pub fn get_status() -> Result<RepoStatus, String> {
let data_path = config::data_dir();
// Try to open as git repo
let repo = match Repository::open(data_path) {
Ok(r) => r,
Err(_) => {
return Ok(RepoStatus {
is_repo: false,
branch: None,
files: Vec::new(),
has_changes: false,
last_commit: None,
});
}
};
// Get current branch
let branch = repo
.head()
.ok()
.and_then(|h| h.shorthand().map(String::from));
// Get file statuses
let mut opts = StatusOptions::new();
opts.include_untracked(true)
.recurse_untracked_dirs(true)
.exclude_submodules(true);
let statuses = repo.statuses(Some(&mut opts)).map_err(|e| e.to_string())?;
let files: Vec<FileStatus> = statuses
.iter()
.filter_map(|entry| {
let path = entry.path()?.to_string();
let status = entry.status();
let status_str = if status.is_index_new() || status.is_wt_new() {
"new"
} else if status.is_index_modified() || status.is_wt_modified() {
"modified"
} else if status.is_index_deleted() || status.is_wt_deleted() {
"deleted"
} else if status.is_index_renamed() || status.is_wt_renamed() {
"renamed"
} else {
return None;
};
Some(FileStatus {
path,
status: status_str.to_string(),
})
})
.collect();
let has_changes = !files.is_empty();
// Get last commit info
let last_commit = repo.head().ok().and_then(|head| {
let commit = head.peel_to_commit().ok()?;
Some(CommitInfo {
id: commit.id().to_string()[..8].to_string(),
message: commit.message()?.trim().to_string(),
timestamp: chrono::DateTime::from_timestamp(commit.time().seconds(), 0)?
.to_rfc3339(),
})
});
Ok(RepoStatus {
is_repo: true,
branch,
files,
has_changes,
last_commit,
})
}
/// Create a commit with all changes
pub fn commit_all(message: Option<&str>) -> Result<CommitInfo, String> {
let data_path = config::data_dir();
let repo = Repository::open(data_path).map_err(|e| format!("Not a git repository: {}", e))?;
// Stage all changes
let mut index = repo.index().map_err(|e| e.to_string())?;
index
.add_all(["*"].iter(), git2::IndexAddOption::DEFAULT, None)
.map_err(|e| e.to_string())?;
index.write().map_err(|e| e.to_string())?;
// Check if there are changes to commit
let tree_id = index.write_tree().map_err(|e| e.to_string())?;
let tree = repo.find_tree(tree_id).map_err(|e| e.to_string())?;
// Get parent commit (if any)
let parent = repo.head().ok().and_then(|h| h.peel_to_commit().ok());
// Check if tree is different from parent
if let Some(ref p) = parent {
if p.tree().map(|t| t.id()) == Ok(tree_id) {
return Err("No changes to commit".to_string());
}
}
// Create signature
let sig = Signature::now("Ironpad", "ironpad@local").map_err(|e| e.to_string())?;
// Generate commit message
let msg = message.unwrap_or_else(|| "Auto-save");
let timestamp = Utc::now().format("%Y-%m-%d %H:%M");
let full_message = format!("{} ({})", msg, timestamp);
// Create commit
let parents: Vec<&git2::Commit> = parent.as_ref().map(|p| vec![p]).unwrap_or_default();
let commit_id = repo
.commit(Some("HEAD"), &sig, &sig, &full_message, &tree, &parents)
.map_err(|e| e.to_string())?;
Ok(CommitInfo {
id: commit_id.to_string()[..8].to_string(),
message: full_message,
timestamp: Utc::now().to_rfc3339(),
})
}
/// Initialize data directory as a git repository if not already
pub fn init_repo() -> Result<(), String> {
let data_path = config::data_dir();
if Repository::open(data_path).is_ok() {
return Ok(()); // Already a repo
}
Repository::init(data_path).map_err(|e| format!("Failed to init repo: {}", e))?;
// Create initial .gitignore
let gitignore_path = data_path.join(".gitignore");
if !gitignore_path.exists() {
std::fs::write(&gitignore_path, "*.tmp\n.DS_Store\n")
.map_err(|e| format!("Failed to create .gitignore: {}", e))?;
}
// Initial commit
commit_all(Some("Initial commit"))?;
Ok(())
}
/// Check for merge conflicts
pub fn check_conflicts() -> Result<Vec<String>, String> {
let data_path = config::data_dir();
let repo = Repository::open(data_path).map_err(|e| format!("Not a git repository: {}", e))?;
let mut conflicts = Vec::new();
// Check for .git/index.lock (another git operation in progress)
let lock_path = data_path.join(".git").join("index.lock");
if lock_path.exists() {
// This isn't a conflict per se, but indicates git is busy
tracing::warn!("Git index.lock exists - another operation may be in progress");
}
// Check status for conflicted files
let mut opts = StatusOptions::new();
opts.include_untracked(false);
let statuses = repo.statuses(Some(&mut opts)).map_err(|e| e.to_string())?;
for entry in statuses.iter() {
let status = entry.status();
// Check for conflict status flags
if status.is_conflicted() {
if let Some(path) = entry.path() {
conflicts.push(path.to_string());
}
}
}
// Also check the index for conflicts
let index = repo.index().map_err(|e| e.to_string())?;
if index.has_conflicts() {
for conflict in index.conflicts().map_err(|e| e.to_string())? {
if let Ok(conflict) = conflict {
if let Some(ancestor) = conflict.ancestor {
if let Some(path) = std::str::from_utf8(&ancestor.path).ok() {
if !conflicts.contains(&path.to_string()) {
conflicts.push(path.to_string());
}
}
}
}
}
}
Ok(conflicts)
}
/// Push to remote repository
pub fn push_to_remote() -> Result<(), String> {
let data_path = config::data_dir();
let repo = Repository::open(data_path).map_err(|e| format!("Not a git repository: {}", e))?;
// Get the current branch
let head = repo.head().map_err(|e| e.to_string())?;
let branch_name = head
.shorthand()
.ok_or_else(|| "Could not get branch name".to_string())?;
// Find the remote (default to "origin")
let mut remote = repo
.find_remote("origin")
.map_err(|e| format!("Remote 'origin' not found: {}", e))?;
// Check if remote URL is configured
let remote_url = remote.url().ok_or_else(|| "No remote URL configured".to_string())?;
if remote_url.is_empty() {
return Err("No remote URL configured".to_string());
}
// Create callbacks for authentication
let mut callbacks = git2::RemoteCallbacks::new();
// Try to use credential helper from git config
callbacks.credentials(|_url, username_from_url, _allowed_types| {
// Try SSH agent first
git2::Cred::ssh_key_from_agent(username_from_url.unwrap_or("git"))
});
// Set up push options
let mut push_options = git2::PushOptions::new();
push_options.remote_callbacks(callbacks);
// Push the current branch
let refspec = format!("refs/heads/{}:refs/heads/{}", branch_name, branch_name);
remote
.push(&[&refspec], Some(&mut push_options))
.map_err(|e| format!("Push failed: {}. Make sure SSH keys are configured.", e))?;
tracing::info!("Successfully pushed to origin/{}", branch_name);
Ok(())
}
/// Check if remote is configured
pub fn has_remote() -> bool {
let data_path = config::data_dir();
if let Ok(repo) = Repository::open(data_path) {
if let Ok(remote) = repo.find_remote("origin") {
return remote.url().is_some();
}
}
false
}
/// Start auto-commit background task.
/// Tries to commit every 60 seconds; commit_all() already handles "no changes" gracefully.
pub fn start_auto_commit() {
tokio::spawn(async move {
let mut interval = interval(Duration::from_secs(60));
loop {
interval.tick().await;
match commit_all(Some("Auto-save")) {
Ok(info) => {
tracing::info!("Auto-commit: {} - {}", info.id, info.message);
}
Err(e) => {
if !e.contains("No changes") {
tracing::warn!("Auto-commit failed: {}", e);
}
}
}
}
});
}
/// Get commit history (most recent first)
pub fn get_log(limit: Option<usize>) -> Result<Vec<CommitDetail>, String> {
let data_path = config::data_dir();
let repo = Repository::open(data_path).map_err(|e| format!("Not a git repository: {}", e))?;
let mut revwalk = repo.revwalk().map_err(|e| e.to_string())?;
revwalk.push_head().map_err(|e| e.to_string())?;
revwalk
.set_sorting(git2::Sort::TIME)
.map_err(|e| e.to_string())?;
let max_commits = limit.unwrap_or(50);
let mut commits = Vec::new();
for (i, oid_result) in revwalk.enumerate() {
if i >= max_commits {
break;
}
let oid = oid_result.map_err(|e| e.to_string())?;
let commit = repo.find_commit(oid).map_err(|e| e.to_string())?;
// Count files changed in this commit
let files_changed = if commit.parent_count() > 0 {
let parent = commit.parent(0).ok();
let parent_tree = parent.as_ref().and_then(|p| p.tree().ok());
let commit_tree = commit.tree().ok();
if let (Some(pt), Some(ct)) = (parent_tree, commit_tree) {
let diff = repo
.diff_tree_to_tree(Some(&pt), Some(&ct), None)
.ok();
diff.map(|d| d.deltas().count()).unwrap_or(0)
} else {
0
}
} else {
// Initial commit - count all files
commit
.tree()
.ok()
.map(|t| count_tree_entries(&t))
.unwrap_or(0)
};
let timestamp =
chrono::DateTime::from_timestamp(commit.time().seconds(), 0)
.map(|dt| dt.to_rfc3339())
.unwrap_or_else(|| "Unknown".to_string());
commits.push(CommitDetail {
id: oid.to_string(),
short_id: oid.to_string()[..8].to_string(),
message: commit.message().unwrap_or("").trim().to_string(),
author: commit.author().name().unwrap_or("Unknown").to_string(),
timestamp,
files_changed,
});
}
Ok(commits)
}
/// Helper to count entries in a tree recursively
fn count_tree_entries(tree: &git2::Tree) -> usize {
tree.iter()
.filter(|entry| entry.kind() == Some(git2::ObjectType::Blob))
.count()
}
/// Get working directory diff (uncommitted changes)
pub fn get_working_diff() -> Result<DiffInfo, String> {
let data_path = config::data_dir();
let repo = Repository::open(data_path).map_err(|e| format!("Not a git repository: {}", e))?;
// Get HEAD tree (or empty tree if no commits)
let head_tree = repo
.head()
.ok()
.and_then(|h| h.peel_to_tree().ok());
// Diff against working directory
let diff = repo
.diff_tree_to_workdir_with_index(head_tree.as_ref(), None)
.map_err(|e| e.to_string())?;
parse_diff(&diff)
}
/// Get diff for a specific commit
pub fn get_commit_diff(commit_id: &str) -> Result<DiffInfo, String> {
let data_path = config::data_dir();
let repo = Repository::open(data_path).map_err(|e| format!("Not a git repository: {}", e))?;
let oid = git2::Oid::from_str(commit_id).map_err(|e| format!("Invalid commit ID: {}", e))?;
let commit = repo
.find_commit(oid)
.map_err(|e| format!("Commit not found: {}", e))?;
let commit_tree = commit.tree().map_err(|e| e.to_string())?;
let parent_tree = if commit.parent_count() > 0 {
commit.parent(0).ok().and_then(|p| p.tree().ok())
} else {
None
};
let diff = repo
.diff_tree_to_tree(parent_tree.as_ref(), Some(&commit_tree), None)
.map_err(|e| e.to_string())?;
parse_diff(&diff)
}
/// Parse a git2::Diff into our DiffInfo structure
fn parse_diff(diff: &git2::Diff) -> Result<DiffInfo, String> {
let stats = diff.stats().map_err(|e| e.to_string())?;
let mut files = Vec::new();
for delta_idx in 0..diff.deltas().count() {
let delta = diff.get_delta(delta_idx).ok_or("Missing delta")?;
let path = delta
.new_file()
.path()
.or_else(|| delta.old_file().path())
.map(|p| p.to_string_lossy().to_string())
.unwrap_or_else(|| "Unknown".to_string());
let status = match delta.status() {
git2::Delta::Added => "added",
git2::Delta::Deleted => "deleted",
git2::Delta::Modified => "modified",
git2::Delta::Renamed => "renamed",
git2::Delta::Copied => "copied",
_ => "unknown",
};
let mut hunks = Vec::new();
let mut additions = 0;
let mut deletions = 0;
// Get patch for this file
if let Ok(patch) = git2::Patch::from_diff(diff, delta_idx) {
if let Some(p) = patch {
for hunk_idx in 0..p.num_hunks() {
if let Ok((hunk, _)) = p.hunk(hunk_idx) {
let mut lines = Vec::new();
for line_idx in 0..p.num_lines_in_hunk(hunk_idx).unwrap_or(0) {
if let Ok(line) = p.line_in_hunk(hunk_idx, line_idx) {
let origin = line.origin();
let content = std::str::from_utf8(line.content())
.unwrap_or("")
.to_string();
match origin {
'+' => additions += 1,
'-' => deletions += 1,
_ => {}
}
lines.push(DiffLine { origin, content });
}
}
hunks.push(DiffHunk {
header: std::str::from_utf8(hunk.header())
.unwrap_or("")
.trim()
.to_string(),
lines,
});
}
}
}
}
files.push(FileDiff {
path,
status: status.to_string(),
additions,
deletions,
hunks,
});
}
Ok(DiffInfo {
files,
stats: DiffStats {
files_changed: stats.files_changed(),
insertions: stats.insertions(),
deletions: stats.deletions(),
},
})
}
/// Get remote repository information
pub fn get_remote_info() -> Result<Option<RemoteInfo>, String> {
let data_path = config::data_dir();
let repo = Repository::open(data_path).map_err(|e| format!("Not a git repository: {}", e))?;
let remote = match repo.find_remote("origin") {
Ok(r) => r,
Err(_) => return Ok(None),
};
let url = remote.url().unwrap_or("").to_string();
if url.is_empty() {
return Ok(None);
}
// Get current branch
let head = match repo.head() {
Ok(h) => h,
Err(_) => {
return Ok(Some(RemoteInfo {
name: "origin".to_string(),
url,
has_upstream: false,
ahead: 0,
behind: 0,
}));
}
};
let branch_name = head.shorthand().unwrap_or("HEAD");
// Try to find upstream branch
let local_branch = repo.find_branch(branch_name, git2::BranchType::Local).ok();
let upstream = local_branch.as_ref().and_then(|b| b.upstream().ok());
let (ahead, behind) = if let Some(ref up) = upstream {
// Calculate ahead/behind
let local_oid = head.target().unwrap_or_else(git2::Oid::zero);
let upstream_oid = up
.get()
.target()
.unwrap_or_else(git2::Oid::zero);
repo.graph_ahead_behind(local_oid, upstream_oid)
.unwrap_or((0, 0))
} else {
(0, 0)
};
Ok(Some(RemoteInfo {
name: "origin".to_string(),
url,
has_upstream: upstream.is_some(),
ahead,
behind,
}))
}
/// Fetch from remote
pub fn fetch_from_remote() -> Result<(), String> {
let data_path = config::data_dir();
let repo = Repository::open(data_path).map_err(|e| format!("Not a git repository: {}", e))?;
let mut remote = repo
.find_remote("origin")
.map_err(|e| format!("Remote 'origin' not found: {}", e))?;
// Create callbacks for authentication
let mut callbacks = git2::RemoteCallbacks::new();
callbacks.credentials(|_url, username_from_url, _allowed_types| {
git2::Cred::ssh_key_from_agent(username_from_url.unwrap_or("git"))
});
let mut fetch_options = git2::FetchOptions::new();
fetch_options.remote_callbacks(callbacks);
remote
.fetch(&[] as &[&str], Some(&mut fetch_options), None)
.map_err(|e| format!("Fetch failed: {}", e))?;
Ok(())
}

View File

@@ -0,0 +1,149 @@
use std::collections::HashMap;
use std::sync::Arc;
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use tokio::sync::RwLock;
/// Type of lock held on a file
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum LockType {
Editor,
TaskView,
}
/// Information about a file lock
#[derive(Debug, Clone, Serialize)]
pub struct LockInfo {
pub path: String,
pub client_id: String,
pub lock_type: LockType,
pub acquired_at: DateTime<Utc>,
}
/// Error type for lock operations
#[derive(Debug, Clone, Serialize)]
pub enum LockError {
AlreadyLocked { holder: String, lock_type: LockType },
NotLocked,
NotOwner,
}
impl std::fmt::Display for LockError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
LockError::AlreadyLocked { holder, lock_type } => {
write!(f, "File already locked by {} ({:?})", holder, lock_type)
}
LockError::NotLocked => write!(f, "File is not locked"),
LockError::NotOwner => write!(f, "You do not own this lock"),
}
}
}
/// Manages file locks across the application
#[derive(Debug, Clone)]
pub struct FileLockManager {
locks: Arc<RwLock<HashMap<String, LockInfo>>>,
}
impl FileLockManager {
pub fn new() -> Self {
Self {
locks: Arc::new(RwLock::new(HashMap::new())),
}
}
/// Attempt to acquire a lock on a file
pub async fn acquire(
&self,
path: &str,
client_id: &str,
lock_type: LockType,
) -> Result<LockInfo, LockError> {
let mut locks = self.locks.write().await;
// Check if already locked
if let Some(existing) = locks.get(path) {
if existing.client_id != client_id {
return Err(LockError::AlreadyLocked {
holder: existing.client_id.clone(),
lock_type: existing.lock_type,
});
}
// Same client - update lock type
}
let lock_info = LockInfo {
path: path.to_string(),
client_id: client_id.to_string(),
lock_type,
acquired_at: Utc::now(),
};
locks.insert(path.to_string(), lock_info.clone());
Ok(lock_info)
}
/// Release a lock on a file
pub async fn release(&self, path: &str, client_id: &str) -> Result<(), LockError> {
let mut locks = self.locks.write().await;
if let Some(existing) = locks.get(path) {
if existing.client_id != client_id {
return Err(LockError::NotOwner);
}
locks.remove(path);
Ok(())
} else {
Err(LockError::NotLocked)
}
}
/// Check if a file is locked
pub async fn is_locked(&self, path: &str) -> Option<LockInfo> {
let locks = self.locks.read().await;
locks.get(path).cloned()
}
/// Check if a file is locked by someone other than the given client
pub async fn is_locked_by_other(&self, path: &str, client_id: &str) -> Option<LockInfo> {
let locks = self.locks.read().await;
locks.get(path).and_then(|lock| {
if lock.client_id != client_id {
Some(lock.clone())
} else {
None
}
})
}
/// Release all locks held by a client (used on disconnect)
pub async fn release_all_for_client(&self, client_id: &str) -> Vec<String> {
let mut locks = self.locks.write().await;
let paths_to_remove: Vec<String> = locks
.iter()
.filter(|(_, lock)| lock.client_id == client_id)
.map(|(path, _)| path.clone())
.collect();
for path in &paths_to_remove {
locks.remove(path);
}
paths_to_remove
}
/// Get all current locks (for debugging/monitoring)
pub async fn get_all_locks(&self) -> Vec<LockInfo> {
let locks = self.locks.read().await;
locks.values().cloned().collect()
}
}
impl Default for FileLockManager {
fn default() -> Self {
Self::new()
}
}

View File

View File

@@ -0,0 +1,6 @@
pub mod filesystem;
pub mod frontmatter;
pub mod git;
pub mod locks;
pub mod markdown;
pub mod search;

View File

@@ -0,0 +1,188 @@
use std::fs;
use std::path::Path;
use std::process::{Command, Stdio};
use serde::Serialize;
use walkdir::WalkDir;
use crate::config;
/// Search result item
#[derive(Debug, Serialize)]
pub struct SearchResult {
pub path: String,
pub title: String,
pub matches: Vec<SearchMatch>,
}
/// Individual match within a file
#[derive(Debug, Serialize)]
pub struct SearchMatch {
pub line_number: u32,
pub line_content: String,
}
/// Search notes using simple string matching
/// Falls back to manual search if ripgrep is not available
pub fn search_notes(query: &str) -> Result<Vec<SearchResult>, String> {
if query.trim().is_empty() {
return Ok(Vec::new());
}
// Try ripgrep first (faster)
match search_with_ripgrep(query) {
Ok(results) => return Ok(results),
Err(e) => {
tracing::debug!("ripgrep not available, falling back to manual search: {}", e);
}
}
// Fallback to manual search
search_manual(query)
}
/// Search using ripgrep (rg)
fn search_with_ripgrep(query: &str) -> Result<Vec<SearchResult>, String> {
let data_dir_str = config::data_dir().to_string_lossy();
let output = Command::new("rg")
.args([
"--json", // JSON output for parsing
"--ignore-case", // Case insensitive
"--type", "md", // Only markdown files
"--max-count", "5", // Max 5 matches per file
query,
&data_dir_str,
])
.stdout(Stdio::piped())
.stderr(Stdio::null())
.output()
.map_err(|e| format!("Failed to run ripgrep: {}", e))?;
if !output.status.success() && output.stdout.is_empty() {
// No matches found or error
return Ok(Vec::new());
}
parse_ripgrep_output(&output.stdout)
}
/// Parse ripgrep JSON output
fn parse_ripgrep_output(output: &[u8]) -> Result<Vec<SearchResult>, String> {
use std::collections::HashMap;
let output_str = String::from_utf8_lossy(output);
let mut results_map: HashMap<String, SearchResult> = HashMap::new();
for line in output_str.lines() {
if let Ok(json) = serde_json::from_str::<serde_json::Value>(line) {
if json["type"] == "match" {
let data = &json["data"];
let path_str = data["path"]["text"].as_str().unwrap_or("");
let line_number = data["line_number"].as_u64().unwrap_or(0) as u32;
let line_content = data["lines"]["text"]
.as_str()
.unwrap_or("")
.trim()
.to_string();
let normalized_path = normalize_path(path_str);
let title = extract_title_from_path(&normalized_path);
let result = results_map.entry(normalized_path.clone()).or_insert_with(|| {
SearchResult {
path: normalized_path,
title,
matches: Vec::new(),
}
});
result.matches.push(SearchMatch {
line_number,
line_content,
});
}
}
}
Ok(results_map.into_values().collect())
}
/// Manual search fallback (no external dependencies)
fn search_manual(query: &str) -> Result<Vec<SearchResult>, String> {
let query_lower = query.to_lowercase();
let root = config::data_dir();
let mut results = Vec::new();
for entry in WalkDir::new(root)
.into_iter()
.filter_entry(|e| !is_ignored(e.path()))
.filter_map(Result::ok)
{
let path = entry.path();
if path.extension().and_then(|s| s.to_str()) != Some("md") {
continue;
}
let content = match fs::read_to_string(path) {
Ok(c) => c,
Err(_) => continue,
};
let mut matches = Vec::new();
for (i, line) in content.lines().enumerate() {
if line.to_lowercase().contains(&query_lower) {
matches.push(SearchMatch {
line_number: (i + 1) as u32,
line_content: line.trim().to_string(),
});
// Limit matches per file
if matches.len() >= 5 {
break;
}
}
}
if !matches.is_empty() {
let normalized_path = normalize_path(&path.to_string_lossy());
let title = extract_title_from_path(&normalized_path);
results.push(SearchResult {
path: normalized_path,
title,
matches,
});
}
}
Ok(results)
}
fn is_ignored(path: &Path) -> bool {
path.components().any(|c| {
matches!(
c.as_os_str().to_str(),
Some(".git") | Some("assets") | Some("archive")
)
})
}
fn normalize_path(path: &str) -> String {
if let Some(idx) = path.find("data") {
let stripped = &path[idx + 5..];
return stripped
.replace('\\', "/")
.trim_start_matches('/')
.to_string();
}
path.replace('\\', "/")
}
fn extract_title_from_path(path: &str) -> String {
Path::new(path)
.file_stem()
.and_then(|s| s.to_str())
.unwrap_or("Untitled")
.to_string()
}

161
backend/src/watcher.rs Normal file
View File

@@ -0,0 +1,161 @@
use std::path::Path;
use std::sync::Arc;
use std::time::Duration;
use notify::{RecursiveMode, Watcher};
use notify_debouncer_full::{new_debouncer, DebouncedEvent};
use tokio::sync::mpsc;
use crate::config;
use crate::websocket::{WsMessage, WsState};
/// Start the file watcher in a background task
pub async fn start_watcher(ws_state: Arc<WsState>) -> Result<(), String> {
let (tx, mut rx) = mpsc::channel::<Vec<DebouncedEvent>>(100);
// Create debouncer with 500ms debounce time
let debouncer = new_debouncer(
Duration::from_millis(500),
None,
move |result: Result<Vec<DebouncedEvent>, Vec<notify::Error>>| {
if let Ok(events) = result {
let _ = tx.blocking_send(events);
}
},
)
.map_err(|e| format!("Failed to create file watcher: {}", e))?;
// Watch the data directory
let data_path = config::data_dir();
if !data_path.exists() {
return Err(format!("Data directory does not exist: {}", data_path.display()));
}
// We need to keep the debouncer alive, so we'll store it
let debouncer = Arc::new(tokio::sync::Mutex::new(debouncer));
{
let mut d = debouncer.lock().await;
d.watcher().watch(data_path, RecursiveMode::Recursive)
.map_err(|e| format!("Failed to watch directory: {}", e))?;
}
tracing::info!("File watcher started for: {}", data_path.display());
// Spawn task to process file events
let ws_state_clone = ws_state.clone();
tokio::spawn(async move {
// Keep debouncer alive
let _debouncer = debouncer;
while let Some(events) = rx.recv().await {
for event in events {
process_event(&event, &ws_state_clone);
}
}
});
Ok(())
}
/// Track recent saves to avoid notifying about our own changes
use std::sync::Mutex;
use std::collections::HashMap;
use std::time::Instant;
lazy_static::lazy_static! {
static ref RECENT_SAVES: Mutex<HashMap<String, Instant>> = Mutex::new(HashMap::new());
}
/// Mark a file as recently saved by us (call this before saving)
pub fn mark_file_saved(path: &str) {
if let Ok(mut saves) = RECENT_SAVES.lock() {
saves.insert(path.to_string(), Instant::now());
}
}
/// Process a single debounced file event
fn process_event(event: &DebouncedEvent, ws_state: &WsState) {
use notify::EventKind;
// Only process markdown files
let paths: Vec<_> = event
.paths
.iter()
.filter(|p| {
p.extension()
.and_then(|e| e.to_str())
.map(|e| e == "md")
.unwrap_or(false)
})
.collect();
if paths.is_empty() {
return;
}
// Skip temporary files (used for atomic writes)
if paths.iter().any(|p| {
p.file_name()
.and_then(|n| n.to_str())
.map(|n| n.starts_with('.') && n.ends_with(".tmp"))
.unwrap_or(false)
}) {
return;
}
// Skip archive and .git directories
if paths.iter().any(|p| {
let s = p.to_string_lossy();
s.contains("archive") || s.contains(".git")
}) {
return;
}
let path_str = normalize_path(&paths[0]);
// Check if this was a recent save by us (within last 2 seconds)
if let Ok(mut saves) = RECENT_SAVES.lock() {
// Clean up old entries
saves.retain(|_, t| t.elapsed().as_secs() < 5);
if let Some(saved_at) = saves.get(&path_str) {
if saved_at.elapsed().as_secs() < 2 {
return; // Skip - this was our own save
}
}
}
let msg = match &event.kind {
EventKind::Create(_) => {
tracing::info!("External file created: {}", path_str);
Some(WsMessage::FileCreated { path: path_str })
}
EventKind::Modify(_) => {
tracing::info!("External file modified: {}", path_str);
Some(WsMessage::FileModified { path: path_str })
}
EventKind::Remove(_) => {
tracing::info!("External file deleted: {}", path_str);
Some(WsMessage::FileDeleted { path: path_str })
}
_ => None,
};
if let Some(msg) = msg {
ws_state.broadcast(msg);
}
}
/// Normalize path for client consumption
fn normalize_path(path: &Path) -> String {
let path_str = path.to_string_lossy();
// Find "data" in the path and strip everything before and including it
if let Some(idx) = path_str.find("data") {
let stripped = &path_str[idx + 5..]; // Skip "data" + separator
return stripped.replace('\\', "/").trim_start_matches('/').to_string();
}
path_str.replace('\\', "/")
}

230
backend/src/websocket.rs Normal file
View File

@@ -0,0 +1,230 @@
use std::collections::HashSet;
use std::sync::Arc;
use axum::{
extract::{
ws::{Message, WebSocket},
State, WebSocketUpgrade,
},
response::IntoResponse,
};
use futures_util::{SinkExt, StreamExt};
use serde::{Deserialize, Serialize};
use tokio::sync::{broadcast, RwLock};
use crate::services::locks::{FileLockManager, LockType};
/// WebSocket message types sent to clients
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "type", content = "payload")]
pub enum WsMessage {
/// A file was created
FileCreated { path: String },
/// A file was modified
FileModified { path: String },
/// A file was deleted
FileDeleted { path: String },
/// A file was renamed
FileRenamed { from: String, to: String },
/// A file was locked
FileLocked {
path: String,
client_id: String,
lock_type: String,
},
/// A file was unlocked
FileUnlocked { path: String },
/// Git conflict detected
GitConflict { files: Vec<String> },
/// Server is sending a ping
Ping,
/// Client connection confirmed
Connected { client_id: String },
/// Error message
Error { message: String },
}
/// Client message types received from clients
#[derive(Debug, Clone, Deserialize)]
#[serde(tag = "type")]
pub enum ClientMessage {
/// Request to lock a file
#[serde(rename = "lock_file")]
LockFile { path: String, lock_type: String },
/// Request to unlock a file
#[serde(rename = "unlock_file")]
UnlockFile { path: String },
/// Ping response
#[serde(rename = "pong")]
Pong,
}
/// Shared state for WebSocket connections
#[derive(Debug, Clone)]
pub struct WsState {
/// Broadcast channel for sending messages to all clients
pub tx: broadcast::Sender<WsMessage>,
/// Set of connected client IDs
pub clients: Arc<RwLock<HashSet<String>>>,
/// File lock manager
pub lock_manager: FileLockManager,
}
impl WsState {
pub fn new() -> Self {
let (tx, _) = broadcast::channel(100);
Self {
tx,
clients: Arc::new(RwLock::new(HashSet::new())),
lock_manager: FileLockManager::new(),
}
}
/// Broadcast a message to all connected clients
pub fn broadcast(&self, msg: WsMessage) {
// Ignore send errors (no receivers)
let _ = self.tx.send(msg);
}
}
impl Default for WsState {
fn default() -> Self {
Self::new()
}
}
/// WebSocket upgrade handler
pub async fn ws_handler(
ws: WebSocketUpgrade,
State(state): State<Arc<WsState>>,
) -> impl IntoResponse {
ws.on_upgrade(move |socket| handle_socket(socket, state))
}
/// Handle individual WebSocket connection
async fn handle_socket(socket: WebSocket, state: Arc<WsState>) {
let client_id = uuid::Uuid::new_v4().to_string();
// Add client to set
{
let mut clients = state.clients.write().await;
clients.insert(client_id.clone());
}
tracing::info!("WebSocket client connected: {}", client_id);
let (mut sender, mut receiver) = socket.split();
// Subscribe to broadcast channel
let mut rx = state.tx.subscribe();
// Send connected message
let connected_msg = WsMessage::Connected {
client_id: client_id.clone(),
};
if let Ok(json) = serde_json::to_string(&connected_msg) {
let _ = sender.send(Message::Text(json.into())).await;
}
// Spawn task to forward broadcast messages to this client
let send_task = tokio::spawn(async move {
while let Ok(msg) = rx.recv().await {
if let Ok(json) = serde_json::to_string(&msg) {
if sender.send(Message::Text(json.into())).await.is_err() {
break;
}
}
}
});
// Handle incoming messages from client
let state_clone = state.clone();
let client_id_clone = client_id.clone();
let recv_task = tokio::spawn(async move {
while let Some(Ok(msg)) = receiver.next().await {
match msg {
Message::Text(text) => {
if let Ok(client_msg) = serde_json::from_str::<ClientMessage>(&text) {
handle_client_message(&state_clone, &client_id_clone, client_msg).await;
} else {
tracing::debug!(
"Unknown message from {}: {}",
client_id_clone,
text
);
}
}
Message::Close(_) => break,
_ => {}
}
}
});
// Wait for either task to complete
tokio::select! {
_ = send_task => {},
_ = recv_task => {},
}
// Clean up on disconnect
// Release all locks held by this client
let released_paths = state.lock_manager.release_all_for_client(&client_id).await;
for path in released_paths {
state.broadcast(WsMessage::FileUnlocked { path });
}
// Remove client from set
{
let mut clients = state.clients.write().await;
clients.remove(&client_id);
}
tracing::info!("WebSocket client disconnected: {}", client_id);
}
/// Handle a message from a client
async fn handle_client_message(state: &Arc<WsState>, client_id: &str, msg: ClientMessage) {
match msg {
ClientMessage::LockFile { path, lock_type } => {
let lock_type = match lock_type.as_str() {
"editor" => LockType::Editor,
"task_view" => LockType::TaskView,
_ => {
tracing::warn!("Unknown lock type: {}", lock_type);
return;
}
};
match state.lock_manager.acquire(&path, client_id, lock_type).await {
Ok(lock_info) => {
let lock_type_str = match lock_info.lock_type {
LockType::Editor => "editor",
LockType::TaskView => "task_view",
};
state.broadcast(WsMessage::FileLocked {
path: lock_info.path,
client_id: lock_info.client_id,
lock_type: lock_type_str.to_string(),
});
}
Err(e) => {
tracing::warn!("Failed to acquire lock: {}", e);
// Could send error back to specific client if needed
}
}
}
ClientMessage::UnlockFile { path } => {
match state.lock_manager.release(&path, client_id).await {
Ok(()) => {
state.broadcast(WsMessage::FileUnlocked { path });
}
Err(e) => {
tracing::warn!("Failed to release lock: {}", e);
}
}
}
ClientMessage::Pong => {
// Heartbeat response, no action needed
}
}
}

0
data/archive/.gitkeep Normal file
View File

0
data/daily/.gitkeep Normal file
View File

0
data/notes/.gitkeep Normal file
View File

View File

0
data/projects/.gitkeep Normal file
View File

659
docs/API.md Normal file
View File

@@ -0,0 +1,659 @@
# Ironpad API Reference
Base URL: `http://localhost:3000`
## Notes
### List Notes
```http
GET /api/notes
```
**Response:**
```json
[
{
"id": "20260205-123456",
"title": "My Note",
"path": "notes/20260205-123456.md",
"created": "2026-02-05T12:34:56Z",
"updated": "2026-02-05T12:34:56Z"
}
]
```
### Create Note
```http
POST /api/notes
Content-Type: application/json
{
"title": "Optional Title",
"content": "# My Note\n\nContent here"
}
```
**Response:** `201 Created`
```json
{
"id": "20260205-123456",
"title": "Optional Title",
"path": "notes/20260205-123456.md",
"content": "# My Note\n\nContent here",
"created": "2026-02-05T12:34:56Z",
"updated": "2026-02-05T12:34:56Z"
}
```
### Get Note
```http
GET /api/notes/:id
```
**Response:**
```json
{
"id": "20260205-123456",
"title": "My Note",
"path": "notes/20260205-123456.md",
"content": "# My Note\n\nFull content...",
"created": "2026-02-05T12:34:56Z",
"updated": "2026-02-05T12:34:56Z"
}
```
### Update Note
```http
PUT /api/notes/:id
Content-Type: application/json
{
"content": "# Updated Content\n\nNew content here"
}
```
**Response:**
```json
{
"id": "20260205-123456",
"title": "Updated Content",
"path": "notes/20260205-123456.md",
"content": "# Updated Content\n\nNew content here",
"created": "2026-02-05T12:34:56Z",
"updated": "2026-02-05T12:35:00Z"
}
```
### Delete (Archive) Note
```http
DELETE /api/notes/:id
```
**Response:** `200 OK`
Note: The note is moved to `archive/`, not permanently deleted.
---
## Projects
### List Projects
```http
GET /api/projects
```
**Response:**
```json
[
{
"id": "ferrite",
"title": "Ferrite",
"description": "A Rust project",
"path": "projects/ferrite",
"created": "2026-02-04T10:00:00Z",
"updated": "2026-02-05T12:00:00Z"
}
]
```
### Create Project
```http
POST /api/projects
Content-Type: application/json
{
"title": "New Project",
"description": "Project description"
}
```
**Response:** `201 Created`
```json
{
"id": "new-project",
"title": "New Project",
"description": "Project description",
"path": "projects/new-project",
"created": "2026-02-05T12:34:56Z",
"updated": "2026-02-05T12:34:56Z"
}
```
### Get Project
```http
GET /api/projects/:id
```
**Response:**
```json
{
"id": "ferrite",
"title": "Ferrite",
"description": "A Rust project",
"path": "projects/ferrite",
"created": "2026-02-04T10:00:00Z",
"updated": "2026-02-05T12:00:00Z"
}
```
### Get Project Content
```http
GET /api/projects/:id/content
```
**Response:**
```json
{
"content": "# Ferrite\n\nProject overview content..."
}
```
### Update Project Content
```http
PUT /api/projects/:id/content
Content-Type: application/json
{
"content": "# Updated Overview\n\nNew content..."
}
```
---
## Project Notes
### List Project Notes
```http
GET /api/projects/:id/notes
```
**Response:**
```json
[
{
"id": "20260205-123456",
"title": "Project Note",
"path": "projects/ferrite/notes/20260205-123456.md",
"created": "2026-02-05T12:34:56Z",
"updated": "2026-02-05T12:34:56Z"
}
]
```
### Create Project Note
```http
POST /api/projects/:id/notes
Content-Type: application/json
{
"title": "New Note",
"content": "Note content..."
}
```
### Get Project Note
```http
GET /api/projects/:id/notes/:noteId
```
### Update Project Note
```http
PUT /api/projects/:id/notes/:noteId
Content-Type: application/json
{
"content": "Updated content..."
}
```
### Delete Project Note
```http
DELETE /api/projects/:id/notes/:noteId
```
---
## Project Tasks
### List Project Tasks
```http
GET /api/projects/:id/tasks
```
**Response:**
```json
[
{
"id": "task-20260205-123456",
"title": "Implement feature X",
"completed": false,
"section": "Active",
"priority": "high",
"due_date": "2026-02-10",
"is_active": true,
"content": "## Requirements\n\n- Item 1\n- Item 2",
"path": "projects/ferrite/tasks/task-20260205-123456.md",
"created": "2026-02-05T12:34:56Z",
"updated": "2026-02-05T12:34:56Z"
}
]
```
### Create Task
```http
POST /api/projects/:id/tasks
Content-Type: application/json
{
"title": "New Task",
"content": "Task description..."
}
```
### Get Task
```http
GET /api/projects/:id/tasks/:taskId
```
### Update Task Content
```http
PUT /api/projects/:id/tasks/:taskId
Content-Type: application/json
{
"content": "Updated task description..."
}
```
### Update Task Metadata
```http
PUT /api/projects/:id/tasks/:taskId/meta
Content-Type: application/json
{
"title": "New Title",
"is_active": false,
"section": "Backlog",
"priority": "low",
"due_date": "2026-02-15"
}
```
### Toggle Task Completion
```http
PUT /api/projects/:id/tasks/:taskId/toggle
```
**Response:**
```json
{
"completed": true
}
```
### Delete Task
```http
DELETE /api/projects/:id/tasks/:taskId
```
---
## All Tasks
### List All Tasks (across projects)
```http
GET /api/tasks
```
Returns tasks from all projects, useful for global task views.
---
## Daily Notes
### List Daily Notes
```http
GET /api/daily
```
**Response:**
```json
[
{
"date": "2026-02-05",
"path": "daily/2026-02-05.md",
"created": "2026-02-05T08:00:00Z",
"updated": "2026-02-05T12:00:00Z"
}
]
```
### Get Today's Note
```http
GET /api/daily/today
```
Creates the daily note if it doesn't exist.
**Response:**
```json
{
"date": "2026-02-05",
"content": "# 2026-02-05\n\n## Todo\n\n- [ ] Task 1",
"path": "daily/2026-02-05.md",
"created": "2026-02-05T08:00:00Z",
"updated": "2026-02-05T12:00:00Z"
}
```
### Get/Create Daily Note by Date
```http
GET /api/daily/:date
POST /api/daily/:date
```
Date format: `YYYY-MM-DD`
---
## Assets
### Upload Asset
```http
POST /api/assets/upload
Content-Type: multipart/form-data
project: ferrite
file: (binary data)
```
**Response:**
```json
{
"url": "/api/assets/ferrite/image-20260205-123456.png",
"filename": "image-20260205-123456.png"
}
```
### Get Asset
```http
GET /api/assets/:project/:filename
```
Returns the binary file with appropriate Content-Type header.
---
## Search
### Search Content
```http
GET /api/search?q=search+term
```
**Response:**
```json
{
"results": [
{
"path": "notes/20260205-123456.md",
"title": "My Note",
"matches": [
{
"line": 5,
"text": "This is a **search term** example"
}
]
}
]
}
```
---
## Git Operations
### Get Status
```http
GET /api/git/status
```
**Response:**
```json
{
"branch": "main",
"ahead": 2,
"behind": 0,
"staged": [],
"modified": ["notes/20260205-123456.md"],
"untracked": [],
"has_conflicts": false
}
```
### Commit Changes
```http
POST /api/git/commit
Content-Type: application/json
{
"message": "Update notes"
}
```
### Push to Remote
```http
POST /api/git/push
```
### Fetch from Remote
```http
POST /api/git/fetch
```
### Get Commit Log
```http
GET /api/git/log?limit=20
```
**Response:**
```json
[
{
"id": "abc123...",
"message": "Update notes",
"author": "User Name",
"date": "2026-02-05T12:34:56Z",
"files_changed": 3
}
]
```
### Get Working Directory Diff
```http
GET /api/git/diff
```
**Response:**
```json
{
"diff": "diff --git a/notes/... "
}
```
### Get Commit Diff
```http
GET /api/git/diff/:commitId
```
### Get Remote Info
```http
GET /api/git/remote
```
**Response:**
```json
{
"name": "origin",
"url": "git@github.com:user/repo.git",
"ahead": 2,
"behind": 0
}
```
### Check for Conflicts
```http
GET /api/git/conflicts
```
**Response:**
```json
{
"has_conflicts": false,
"files": []
}
```
---
## WebSocket
### Connect
```
WS /ws
```
### Messages (Client → Server)
**Lock File:**
```json
{
"type": "lock_file",
"path": "notes/20260205-123456.md",
"lock_type": "editor"
}
```
**Unlock File:**
```json
{
"type": "unlock_file",
"path": "notes/20260205-123456.md"
}
```
### Messages (Server → Client)
**File Locked:**
```json
{
"type": "file_locked",
"path": "notes/20260205-123456.md",
"client_id": "client-123"
}
```
**File Unlocked:**
```json
{
"type": "file_unlocked",
"path": "notes/20260205-123456.md"
}
```
**File Modified (broadcast):**
```json
{
"type": "file_modified",
"path": "notes/20260205-123456.md"
}
```
**Git Status Update:**
```json
{
"type": "git_status",
"status": { ... }
}
```
---
## Error Responses
All endpoints return errors in this format:
```json
{
"error": "Human-readable error message",
"code": "ERROR_CODE"
}
```
### Common Error Codes
| Code | HTTP Status | Description |
|------|-------------|-------------|
| `NOT_FOUND` | 404 | Resource doesn't exist |
| `BAD_REQUEST` | 400 | Invalid request data |
| `CONFLICT` | 409 | Resource conflict (e.g., Git) |
| `INTERNAL_ERROR` | 500 | Server error |

416
docs/ARCHITECTURE.md Normal file
View File

@@ -0,0 +1,416 @@
# Ironpad Architecture
This document describes the technical architecture of Ironpad.
## System Overview
```
┌─────────────────────────────────────────────────────────────────┐
│ Browser │
│ ┌─────────────────────────────────────────────────────────────┐│
│ │ Vue 3 SPA ││
│ │ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌─────────────┐ ││
│ │ │ Views │ │Components│ │ Stores │ │ Composables │ ││
│ │ └────┬─────┘ └────┬─────┘ └────┬─────┘ └──────┬──────┘ ││
│ │ │ │ │ │ ││
│ │ └─────────────┴──────┬──────┴───────────────┘ ││
│ │ │ ││
│ │ ┌───────▼───────┐ ││
│ │ │ API Client │ ││
│ └────────────────────┴───────┬───────┴────────────────────────┘│
└───────────────────────────────┼─────────────────────────────────┘
HTTP REST │ WebSocket
┌───────────────────────────────┼─────────────────────────────────┐
│ │ │
│ ┌────────────────────────────▼────────────────────────────────┐│
│ │ Axum Router ││
│ │ ┌─────────────────────────────────────────────────────────┐││
│ │ │ Routes │││
│ │ │ /api/notes /api/projects /api/tasks /api/git /ws │││
│ │ └───────────────────────────┬─────────────────────────────┘││
│ └──────────────────────────────┼───────────────────────────────┘│
│ │ │
│ ┌──────────────────────────────▼───────────────────────────────┐│
│ │ Services ││
│ │ ┌──────────┐ ┌───────────┐ ┌──────┐ ┌───────┐ ┌──────┐ ││
│ │ │Filesystem│ │Frontmatter│ │ Git │ │Search │ │Locks │ ││
│ │ └────┬─────┘ └─────┬─────┘ └──┬───┘ └───┬───┘ └──┬───┘ ││
│ └───────┼──────────────┼───────────┼──────────┼─────────┼──────┘│
│ │ │ │ │ │ │
│ └──────────────┴─────┬─────┴──────────┴─────────┘ │
│ │ │
│ ┌──────▼──────┐ │
│ │ File System │ │
│ │ (data/) │ │
│ └─────────────┘ │
│ │
│ Rust Backend │
└──────────────────────────────────────────────────────────────────┘
```
## Core Principles
### 1. Files Are the Database
All data is stored as Markdown files with YAML frontmatter:
```markdown
---
id: note-20260205-123456
title: My Note
created: 2026-02-05T12:34:56Z
updated: 2026-02-05T12:34:56Z
---
# My Note
Content goes here...
```
**Benefits:**
- Portable — files can be copied, backed up, synced
- Editable — any text editor works
- Versionable — Git tracks all changes
- Debuggable — human-readable format
### 2. Backend Owns Metadata
The backend automatically manages:
- `id` — Generated from timestamp (YYYYMMDD-HHMMSS)
- `created` — Set once when file is created
- `updated` — Updated on every save
Clients send content; backend handles metadata consistency.
### 3. Local-First
The application works fully offline:
- No cloud dependencies
- No external API calls
- Git push is optional
## Backend Architecture
### Technology Stack
- **Rust** — Memory safety, performance
- **Axum 0.8** — Async web framework
- **Tokio** — Async runtime
- **serde/serde_yaml** — Serialization
- **notify** — File system watching
### Service Layer
```
services/
├── filesystem.rs # File read/write operations
├── frontmatter.rs # YAML parsing/generation
├── git.rs # Git CLI wrapper
├── locks.rs # File locking state
├── markdown.rs # Markdown utilities
└── search.rs # ripgrep integration
```
#### Filesystem Service
Handles all file operations with atomic writes:
```rust
// Atomic write pattern
fn write_note(path: &Path, content: &str) -> Result<()> {
let temp = path.with_extension("tmp");
fs::write(&temp, content)?;
fs::rename(temp, path)?; // Atomic on most filesystems
Ok(())
}
```
#### Frontmatter Service
Parses and generates YAML frontmatter:
```rust
struct Frontmatter {
id: String,
title: Option<String>,
created: DateTime<Utc>,
updated: DateTime<Utc>,
// ... other fields
}
```
#### Git Service
Wraps Git CLI commands:
```rust
impl GitService {
fn status(&self) -> Result<GitStatus>;
fn commit(&self, message: &str) -> Result<()>;
fn push(&self) -> Result<()>;
fn log(&self, limit: usize) -> Result<Vec<Commit>>;
fn diff(&self, commit: Option<&str>) -> Result<String>;
}
```
Auto-commit runs every 60 seconds when changes exist.
### WebSocket System
Real-time updates via WebSocket:
```
Client Server
│ │
│──── connect ─────────▶│
│◀─── accepted ─────────│
│ │
│──── lock_file ───────▶│
│◀─── file_locked ──────│
│ │
│ │ (file changed on disk)
│◀─── file_modified ────│
│ │
│──── unlock_file ─────▶│
│◀─── file_unlocked ────│
```
**Message Types:**
- `lock_file` / `unlock_file` — File locking for concurrent editing
- `file_modified` — Broadcast when files change on disk
- `git_status` — Git status updates
### File Watcher
Uses `notify` crate to watch the data directory:
```rust
// Debounce: 500ms to batch rapid changes
// Filter: Ignores changes from own writes
watcher.watch(data_path, RecursiveMode::Recursive)?;
```
## Frontend Architecture
### Technology Stack
- **Vue 3** — Composition API
- **TypeScript** — Type safety
- **Vite** — Build tooling
- **Pinia** — State management
- **Vue Router** — Navigation
- **Milkdown** — WYSIWYG editor
### Component Hierarchy
```
App.vue
├── Sidebar.vue
│ ├── NoteList.vue
│ ├── ProjectList.vue
│ └── GitStatus.vue
├── TopBar.vue
├── SearchPanel.vue
├── GitPanel.vue
└── <router-view>
├── NotesView.vue
├── ProjectView.vue
├── ProjectNotesView.vue
├── TasksView.vue
└── DailyView.vue
```
### State Management (Pinia)
Each domain has a dedicated store:
```typescript
// Example: notesStore
export const useNotesStore = defineStore('notes', () => {
const notes = ref<Note[]>([])
const currentNote = ref<NoteWithContent | null>(null)
const saveStatus = ref<'idle' | 'saving' | 'saved' | 'error'>('idle')
async function loadNote(id: string) { ... }
async function saveNote(content: string) { ... }
return { notes, currentNote, saveStatus, loadNote, saveNote }
})
```
### Milkdown Editor Integration
The editor uses a two-component architecture:
```
MilkdownEditor.vue (wrapper)
└── MilkdownEditorCore.vue (actual editor)
```
**Critical Lifecycle:**
1. `MilkdownProvider` provides Vue context
2. `useEditor` hook creates `Crepe` instance
3. `Crepe.editor` is the ProseMirror editor
4. `editor.action(replaceAll(content))` updates content
**Key Pattern:** Content must be set BEFORE the editor key changes:
```javascript
// View component
watch(noteId, async (newId) => {
const note = await api.getNote(newId)
// CORRECT ORDER:
editorContent.value = note.content // 1. Set content
editorKey.value = newId // 2. Recreate editor
})
```
### Auto-save System
Smart auto-save that prevents unnecessary saves:
```javascript
// Track original content
const lastSavedContent = ref<string | null>(null)
// Only save when content differs
watch(editorContent, (newContent) => {
if (lastSavedContent.value !== null &&
newContent !== lastSavedContent.value) {
scheduleAutoSave() // 1-second debounce
}
})
```
## Data Model
### Note
```typescript
interface Note {
id: string // e.g., "20260205-123456"
title?: string
path: string // e.g., "notes/20260205-123456.md"
created: string // ISO 8601
updated: string
}
interface NoteWithContent extends Note {
content: string // Markdown body
}
```
### Project
```typescript
interface Project {
id: string // e.g., "ferrite" (slug)
title: string
description?: string
path: string
created: string
updated: string
}
```
### Task
```typescript
interface Task {
id: string // e.g., "task-20260205-123456"
title: string
completed: boolean
section?: string // "Active" | "Backlog"
priority?: string
due_date?: string
is_active: boolean
content: string // Markdown description
path: string
created: string
updated: string
}
```
## API Design
### REST Conventions
- `GET /api/resource` — List all
- `POST /api/resource` — Create new
- `GET /api/resource/:id` — Get one
- `PUT /api/resource/:id` — Update
- `DELETE /api/resource/:id` — Delete (usually archives)
### Error Handling
```json
{
"error": "Note not found",
"code": "NOT_FOUND"
}
```
HTTP status codes:
- `200` — Success
- `201` — Created
- `400` — Bad request
- `404` — Not found
- `500` — Server error
## Security Considerations
### Current State
Ironpad is designed for **local, single-user** operation:
- No authentication (local access assumed)
- No HTTPS (localhost only)
- No input sanitization for XSS (trusted user)
### Production Deployment
For multi-user or remote deployment:
1. Add authentication (JWT, session-based)
2. Enable HTTPS
3. Sanitize markdown output
4. Rate limit API endpoints
5. Validate file paths to prevent directory traversal
## Performance Considerations
### Backend
- **Atomic writes** — Prevent corruption on crash
- **File caching** — Read once, cache in memory (not yet implemented)
- **Ripgrep search** — Fast full-text search
### Frontend
- **Virtual scrolling** — For large note lists (not yet needed)
- **Debounced saves** — 1-second delay batches rapid edits
- **Lazy loading** — Routes loaded on demand
## Future Considerations
### Scalability
Current design handles ~5000 files comfortably. For larger datasets:
- Add Tantivy full-text search index
- Implement pagination for note lists
- Add lazy loading for project trees
### Features
See `ai-context.md` for planned features:
- Tag extraction and filtering
- Backlinks between notes
- Graph view
- Export (PDF/HTML)
- Custom themes

View File

@@ -0,0 +1,221 @@
# Ironpad — Implementation Checklist
This checklist tracks **what is planned vs what is done**, based on the PRD.
It is the authoritative execution status for the project.
---
## Phase 0 — Preparation ✅ (COMPLETED)
### Repository & Tooling
- [x] Create project root structure
- [x] Initialize Rust backend (`ironpad`, edition 2021)
- [x] Add backend dependencies (Axum, Tokio, notify, git2, etc.)
- [x] Verify backend builds (`cargo check`)
### Backend Scaffolding
- [x] Create `routes/`, `services/`, `models/` modules
- [x] Create placeholder files for all planned backend components
- [x] Prepare WebSocket and file watcher modules
### Data Layer
- [x] Create `data/` directory structure
- [x] Create initial files (`index.md`, `inbox.md`)
- [x] Initialize `data/` as its own Git repository
### Project Meta
- [x] Create `ai-context.md`
- [x] Create implementation checklist
---
## Phase 1 — MVP ✅ (COMPLETED)
### Backend Core
- [x] Implement `main.rs` (Axum server bootstrap)
- [x] Dynamic port selection (30003010)
- [x] Auto-open system browser on startup
- [x] Serve static frontend files (production path)
### Notes (CRUD)
- [x] List notes from filesystem
- [x] Read markdown file by ID
- [x] Create new note with auto-generated frontmatter
- [x] Update note with auto-save + timestamp update
- [x] Archive note on delete (move to `data/archive/`)
### Frontmatter Automation
- [x] Parse/serialize frontmatter
- [x] Deterministic ID from path
- [x] Auto-manage `created`/`updated` timestamps
- [x] Preserve user-defined fields
### Frontend (Basic)
- [x] Vue 3 + Vite setup
- [x] Note list sidebar
- [x] Note viewer/editor (textarea)
- [x] Create/archive note actions
- [x] Auto-save on edit
---
## Phase 2 — Core Daily Driver ✅ (COMPLETED)
### Real-Time Sync
- [x] File system watching (`notify` crate)
- [x] WebSocket server for real-time updates
- [x] External edit detection + UI notifications
- [x] Filter out own saves from notifications
### Search
- [x] Full-text search (ripgrep with fallback)
- [x] Search endpoint (`GET /api/search?q=`)
- [x] UI search integration (Ctrl+K)
### Git Integration
- [x] Git status endpoint
- [x] Auto-commit (60-second batching)
- [x] Manual commit button
- [x] Git status indicator in UI
### Projects
- [x] Project creation (folder + `index.md` + `assets/`)
- [x] List projects API
- [x] Project task file creation (`tasks.md`)
### Tasks (Basic)
- [x] Task parsing from markdown checkboxes
- [x] Tasks API (list all tasks)
- [x] Task view in sidebar
---
## Phase 3 — Full PRD Compliance ✅ (COMPLETED)
### Projects & Tasks (Per PRD Section 7.2, 7.3)
- [x] Project-specific task endpoint (`GET /api/projects/:id/tasks`)
- [x] Task toggle endpoint (update checkbox state)
- [x] Add task via UI (append to `tasks.md`)
- [x] Task sections: Active, Completed, Backlog
- [x] Project task view at `/projects/:id/tasks` route
### File Locking (Per PRD Section 7.7)
- [x] Backend tracks open files via WebSocket
- [x] File lock when Task View opens
- [x] Editor shows "Read-Only" if file locked
- [x] Auto-unlock when view closes
### Daily Notes (Per PRD Section 6)
- [x] Create `data/daily/` directory
- [x] Daily note endpoint (create/get today's note)
- [x] Daily note templates
- [x] Daily notes in sidebar
### CodeMirror 6 Editor (Per PRD Section 9.3)
- [x] Install CodeMirror 6 dependencies
- [x] Replace textarea with CodeMirror
- [x] Markdown syntax highlighting
- [x] Line numbers
- [x] Keyboard shortcuts
### Markdown Preview (Per PRD Section 5)
- [x] Split view (editor + preview)
- [x] Markdown-it rendering
- [x] CommonMark consistency
### Assets API (Per PRD Section 8)
- [x] `POST /api/assets/upload` endpoint
- [x] `GET /api/assets/:project/:file` endpoint
- [x] Image upload UI in editor
### Git Advanced (Per PRD Section 7.5)
- [x] Git conflict detection
- [x] Conflict warning banner in UI
- [x] `POST /api/git/push` endpoint
- [x] `GET /api/git/conflicts` endpoint
### Frontend Architecture (Per PRD Section 14)
- [x] Vue Router for navigation
- [x] Pinia state management
- [x] Separate view components (NotesView, TasksView, ProjectView)
- [x] WebSocket composable
---
## Phase 4 — Enhanced Task System ✅ (COMPLETED)
### Dashboard
- [x] Cross-project dashboard as home page (`/`)
- [x] Project cards with active task counts and summaries
- [x] Click-through to project or task detail
- [x] Clickable "Ironpad" title navigates to dashboard
### Tags
- [x] Tags field in task frontmatter (YAML sequence)
- [x] Backend parses/writes tags on task CRUD
- [x] Tag pills displayed on task list items
- [x] Tag filter bar — click to filter tasks by tag
- [x] Tag editor in task detail panel with autocomplete
- [x] `projectTags` computed getter for all unique tags in project
### Subtasks
- [x] `parent_id` field in task frontmatter
- [x] Backend accepts `parent_id` on task creation
- [x] Task list groups subtasks under parent (indented)
- [x] Subtask count badge on parent tasks (completed/total)
- [x] Subtask panel in task detail with inline add
- [x] Subtasks clickable to view/edit
### Recurring Tasks
- [x] `recurrence` and `recurrence_interval` fields in frontmatter
- [x] Backend auto-creates next instance on recurring task completion
- [x] Due date advanced by interval (daily/weekly/monthly/yearly)
- [x] Recurrence picker (dropdown) in task detail panel
- [x] Recurrence indicator on task list items
### Calendar View
- [x] Month grid calendar at `/calendar`
- [x] Tasks with due dates plotted on day cells
- [x] Daily notes shown as blue dots
- [x] Color-coded urgency (overdue/today/soon)
- [x] Month navigation (prev/next) + Today button
- [x] Click task → navigate to detail, click date → daily note
- [x] Calendar link in sidebar navigation
### Due Date
- [x] Inline date picker in task detail panel
- [x] Clear due date button
- [x] Due date display with color-coded urgency on task items
---
## Phase 5 — Polish
- [ ] UI polish and animations
- [ ] Responsive sidebar
- [ ] Better error handling/messages
- [ ] Loading states
---
## Phase 6 — Future / Optional
- [ ] Global hotkey (Ctrl+Shift+Space)
- [ ] System tray mode
- [ ] Backlinks between notes
- [ ] Graph view
- [ ] Export (PDF / HTML)
- [ ] Custom themes
- [ ] Tantivy search (if >5000 notes)
- [ ] Task dependencies (blocked by)
- [ ] Time estimates on tasks
- [ ] Calendar drag-and-drop rescheduling
- [ ] Week/day calendar views
---
## Rules
- No item is marked complete unless it is implemented and verified.
- New features must be added to this checklist before implementation.
- If it's not on this list, it's out of scope.

View File

@@ -0,0 +1,128 @@
# Ironpad - Chat Handover Document
**Date:** 2026-02-05
**Context:** See `ai-context.md` for full project overview
---
## Session Summary
This session focused on fixing a critical bug where **notes and tasks displayed stale/wrong content** when switching between items. The issue caused data loss as the wrong content was being saved to the wrong files.
---
## What Was Fixed
### Problem: Stale Content When Switching Notes/Tasks
**Symptoms:**
- Click note A → shows content correctly
- Click note B → still shows note A's content
- Refresh sometimes fixes it, sometimes shows blank
- Auto-save then overwrites note B with note A's content (DATA LOSS)
**Root Cause:**
The Milkdown WYSIWYG editor wasn't properly recreating when switching items. Two issues:
1. **Module-level variables in `MilkdownEditorCore.vue`** - State like `currentContent` was persisting across component recreations because they were `let` variables instead of Vue `ref`s.
2. **Race condition in view components** - The editor key was changing BEFORE content was loaded:
```
noteId changes → editor recreates with empty content → content loads → too late
```
**Solution:**
1. Converted module-level `let` variables to `ref`s in `MilkdownEditorCore.vue`
2. Added retry mechanism for applying pending content
3. Introduced separate `editorKey` ref in all view components that only updates AFTER content is loaded
4. Added guards to prevent emitting stale content
**Files Modified:**
- `frontend/src/components/MilkdownEditorCore.vue`
- `frontend/src/components/MilkdownEditor.vue`
- `frontend/src/views/ProjectNotesView.vue`
- `frontend/src/views/TasksView.vue`
- `frontend/src/views/NotesView.vue`
---
## Outstanding Issues
All major issues from this session have been resolved:
1. **Auto-save aggressiveness** - FIXED: Now tracks "last saved content" and only saves when actual changes are made
2. **Documentation** - FIXED: Added README.md, docs/ARCHITECTURE.md, docs/API.md
---
## Technical Context for Future Sessions
### Milkdown Editor Lifecycle (Critical Knowledge)
The Milkdown editor (WYSIWYG markdown) has a complex lifecycle:
1. `MilkdownProvider` provides Vue context
2. `useEditor` hook creates the `Crepe` instance
3. `Crepe.editor` is the actual Milkdown Editor
4. `editor.action(replaceAll(content))` updates content
5. BUT `editor.action` isn't immediately available after `useEditor` returns
**Key Pattern:** Always set content BEFORE changing the editor key:
```javascript
// CORRECT
editorContent.value = newContent
editorKey.value = newId // Editor recreates with correct defaultValue
// WRONG
editorKey.value = newId // Editor recreates with stale/empty content
editorContent.value = newContent // Too late!
```
### Project Structure
```
ironpad/
├── backend/ # Rust Axum server (API only)
├── frontend/ # Vue 3 SPA
│ └── src/
│ ├── components/
│ │ ├── MilkdownEditor.vue # Wrapper component
│ │ └── MilkdownEditorCore.vue # Actual editor (key file!)
│ ├── views/
│ │ ├── NotesView.vue # Standalone notes
│ │ ├── ProjectNotesView.vue # Project-specific notes
│ │ └── TasksView.vue # Project tasks
│ └── stores/ # Pinia state management
└── data/ # Markdown files (source of truth)
```
---
## Recommended Next Steps
1. ~~**Fix auto-save aggressiveness**~~ - DONE: Uses `lastSavedContent` to track actual changes
2. ~~**Create proper README.md**~~ - DONE: See `/README.md`, `/frontend/README.md`
3. ~~**Add developer documentation**~~ - DONE: See `/docs/ARCHITECTURE.md`, `/docs/API.md`
4. **Consider adding tests** - At minimum, test the content switching logic
---
## Commands Reference
```bash
# Backend (from backend/)
cargo run # API server on :3000
# Frontend (from frontend/)
npm run dev # Dev server on :5173
npm run build # Production build
```
---
## Notes
- Windows + PowerShell environment
- Files are the database (no SQL)
- Git auto-commits every 60 seconds
- See `ai-context.md` for full feature list and API endpoints

1004
docs/ai-workflow/PRD.md Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,22 @@
# AI-Assisted Development: How Ironpad Was Built
Ironpad was built entirely using AI-assisted development. Not just the code -- the architecture, the PRD, the task breakdowns, and the debugging were all done through structured collaboration with AI models.
We share this process openly as part of the **Open Method** philosophy: not just open source code, but open development process.
## What's in This Folder
| Document | Description |
|----------|-------------|
| [method.md](method.md) | The complete AI development workflow: PRD-first, task decomposition, handover system |
| [tools.md](tools.md) | Every tool used, how it fits in, and why |
| [lessons-learned.md](lessons-learned.md) | What worked, what didn't, and the 200K to 1M context window shift |
## Articles
- [The AI Development Workflow I Actually Use](https://dev.to/olaproeis/the-ai-development-workflow-i-actually-use-549i) -- The original workflow article describing the method
- [From 200K to 1M: How Claude Opus 4.6 Changed My AI Development Workflow](docs/article-opus-4.6-workflow.md) -- Follow-up on the context window leap
## Key Takeaway
The tools keep getting better, but **the process of using them well still matters**. A 1 million token context window doesn't help if you don't know what to ask for. Structure your work, be specific, maintain context, verify results.

View File

@@ -0,0 +1,133 @@
# Lessons Learned
What worked, what didn't, and what changed when the context window went from 200K to 1M tokens.
## What Worked Well
### 1. PRD-First Development
Writing a detailed PRD before any code was the single highest-leverage activity. The AI produces dramatically better code when it knows exactly what success looks like.
Ironpad's PRD went through 3 versions. Each round of multi-AI review caught issues that would have been expensive to fix later:
- Concurrent editing race conditions (file locking needed)
- File watcher integration (external editor support)
- Git conflict handling (graceful degradation, not crashes)
- Frontmatter automation (users shouldn't manually edit metadata)
**Lesson:** Time spent on the PRD pays off 10x during implementation.
### 2. Rust's Strict Compiler
Rust stands out for AI-assisted development because the compiler is extraordinarily strict. It catches:
- Memory safety issues
- Type mismatches
- Lifetime problems
- Unused variables and imports
- Missing error handling
With dynamic languages, bugs hide until runtime. With Rust, the AI gets immediate, precise feedback on what's broken. The feedback loop is tighter and more reliable.
`cargo check` became the primary verification tool. If it compiles, a large category of bugs is already eliminated.
**Lesson:** A strict compiler is a massive advantage for AI-generated code.
### 3. The ai-context.md Pattern
Maintaining a lean (~100 line) architectural reference that tells the AI how to write code for this specific project eliminated a whole class of "doesn't fit the codebase" problems.
Without it, the AI would invent new patterns, use different naming conventions, or structure code differently from the existing codebase. With it, code consistently matched existing patterns.
**Lesson:** A small context document is worth more than a large architecture doc. The AI needs a cheat sheet, not a textbook.
### 4. Fresh Chats Over Long Conversations
Context accumulates noise. By the third task in a single chat, the AI references irrelevant earlier context. Starting fresh with a focused handover produced consistently better results.
**Lesson:** Shorter, focused sessions beat long wandering ones.
## What Didn't Work
### 1. Trusting "This Should Work"
The AI confidently says "this should work" when it doesn't. Every single time. Without exception.
Early on, I'd take the AI's word and move on. Then things would break two features later when the untested code interacted with something else.
**Fix:** Test everything yourself. Run the feature. Click the buttons. Try the edge cases. The AI writes code; you verify the product.
### 2. Vague Requirements
"Add search" produces mediocre results. "Add full-text search with ripgrep, triggered by Ctrl+K, showing filename and matching line with context, limited to 5 matches per file, falling back to manual string search if ripgrep isn't available" produces excellent results.
**Fix:** Be specific. The more precise the requirement, the better the code.
### 3. Over-Engineering
The AI tends to add abstractions, patterns, and generalization that aren't needed yet. It builds for a future that may never come.
**Fix:** Explicitly state YAGNI in the context. Call out when something is over-engineered. The AI responds well to "simplify this."
### 4. Ignoring the Editor Lifecycle
The Milkdown WYSIWYG editor had a complex initialization lifecycle that the AI didn't fully understand. This caused a critical bug where switching between notes showed stale content, leading to data loss.
**Fix:** Document critical component lifecycles in ai-context.md. The "Milkdown Editor Lifecycle" section was added after this bug and prevented similar issues.
## The 200K to 1M Context Shift
This was the most significant change in the project's development workflow.
### Before: 200K Tokens (Claude Opus 4.5)
| Aspect | Reality |
|--------|---------|
| Files in context | ~3-5 at once |
| Task granularity | Must split features into 3-5 micro-tasks |
| Handovers | Required between every task |
| Cross-file bugs | Hard to find (AI can't see all files) |
| Refactors | Multi-session, risk of inconsistency |
| Overhead per task | ~15-20 min (handover + context setup) |
### After: 1M Tokens (Claude Opus 4.6)
| Aspect | Reality |
|--------|---------|
| Files in context | Entire codebase (80+ files) |
| Task granularity | Full features in one session |
| Handovers | Only needed between days/sessions |
| Cross-file bugs | Found automatically (AI sees everything) |
| Refactors | Single session, guaranteed consistency |
| Overhead per task | ~0 min |
### The Codebase Audit
The clearest demonstration of the shift: loading the entire Ironpad codebase into a single context and asking "what's wrong?"
The AI found 16 issues, including:
- **Auto-commit silently broken** -- A flag that was never set to `true` anywhere in the codebase. Finding this required reading `main.rs`, `git.rs`, and every route handler simultaneously.
- **Operator precedence bug** -- `0 > 0` evaluated before `??` due to JavaScript precedence rules. Subtle and easy to miss.
- **Missing atomic writes** -- Only one of eight write paths used the safe atomic write pattern.
14 of 16 issues were fixed in a single session. Zero compilation errors introduced.
This type of comprehensive audit was not practical at 200K tokens.
### What Didn't Change
The 1M context window doesn't change everything:
- **PRDs are still essential.** More context doesn't substitute for clear requirements.
- **Testing is still essential.** The AI still says "this should work" when it doesn't.
- **Specificity still matters.** Vague asks still produce vague results.
- **Handovers still matter** between sessions (sleep, context switches, etc.)
The context window is a force multiplier, not a replacement for process.
## Advice for Others
1. **Start with the PRD.** It's the highest-leverage activity.
2. **Use a strict language if you can.** Rust, TypeScript (strict mode), Go -- anything with a compiler that catches bugs.
3. **Maintain ai-context.md.** Keep it under 100 lines. Update it when patterns change.
4. **Test everything.** Don't read code. Run the thing.
5. **Use multiple AI models.** They have different blind spots.
6. **Be specific.** The more precise the requirement, the better the result.
7. **Keep sessions focused.** One task, one chat (at 200K). One feature, one chat (at 1M).

119
docs/ai-workflow/method.md Normal file
View File

@@ -0,0 +1,119 @@
# The Development Method
This document describes the AI-assisted development workflow used to build Ironpad from scratch.
## Overview
The method has six phases, applied iteratively for each feature:
1. **Multi-AI Consultation** -- Get perspectives from multiple AI models before coding
2. **PRD Creation** -- Write a detailed product requirements document
3. **Task Decomposition** -- Break the PRD into ordered, dependency-aware tasks
4. **Context Loading** -- Feed the AI current documentation and project context
5. **Implementation** -- Build features in focused sessions with handovers
6. **Verification** -- Test everything yourself; don't trust "this should work"
## Phase 1: Multi-AI Consultation
Before writing any code, discuss the idea with different AI assistants:
- **Claude** for architecture and code design
- **Perplexity** for research on libraries, crate versions, and known issues
- **Gemini** for alternative perspectives and catching blind spots
Each AI has different strengths and blind spots. Five minutes getting multiple opinions saves hours of rework later.
**Example from Ironpad:** When designing the task system, one model suggested storing tasks as checkboxes in a single `tasks.md` file. Another pointed out that individual task files with frontmatter would be more flexible and avoid concurrent edit conflicts. We went with individual files, which turned out to be the right call.
## Phase 2: PRD Creation
Task Master (and AI in general) produces dramatically better results when it knows exactly what success looks like. The PRD captures:
- Problem statement and goals
- Detailed feature specifications
- Technical architecture decisions
- API design
- Data model
- Edge cases and error handling
- Non-goals (equally important)
After drafting, run the PRD through other AIs for review. Iterate until it's tight.
**Ironpad's PRD** went through 3 versions, incorporating feedback about concurrency control, file watching, git conflict handling, and frontmatter automation -- all before a single line of code was written.
## Phase 3: Task Decomposition
Use Task Master to parse the PRD into structured tasks with dependencies. Each task should have:
- Clear inputs (what files/context are needed)
- Clear outputs (what gets created/changed)
- Explicit dependencies (what must be done first)
- Acceptance criteria (how to verify it works)
You don't need Task Master specifically. What matters is having explicit, ordered tasks rather than vague goals like "add search."
## Phase 4: Context Loading
AI models have training cutoffs. The library docs they know might be outdated.
- **Context7** (MCP tool) pulls current documentation into context for any library
- **ai-context.md** is a lean architectural reference (~100 lines) telling the AI how to write code that fits the codebase
- **Handover documents** carry context between sessions
### The ai-context.md Pattern
This file tells the AI *how* to write code that belongs in this project:
- Module structure and naming conventions
- Key types and their relationships
- Framework idioms (Axum patterns, Vue composition API patterns)
- Critical gotchas that cause bugs (e.g., Milkdown editor lifecycle)
- Current implementation status
It's not a full architecture doc. It's a cheat sheet for the AI.
## Phase 5: Implementation
### The Handover System (200K Context)
With 200K token models, each task gets a fresh chat:
1. Open new chat
2. Paste handover document with: rules, relevant files, current task
3. Work on task until done
4. AI updates the handover document for the next task
5. Close chat, repeat
**Why fresh chats?** Context accumulates noise. Three tasks in, the AI references irrelevant stuff from earlier. Starting clean with a focused handover produces better results.
### The Full-Context Approach (1M Context)
With 1M token models (Claude Opus 4.6), the workflow simplifies:
1. Load the entire codebase into context
2. Work on features directly -- the AI sees everything
3. Use handovers only for session boundaries (end of day, sleep, etc.)
The handover system doesn't disappear -- it shifts from "required between every task" to "useful between sessions."
## Phase 6: Verification
The AI writes the code. You verify the product.
- Run the feature and see if it works
- Test edge cases manually
- Check that nothing else broke
- Use compiler output (`cargo check`) and linters as mechanical verification
Don't read code line by line. Run the thing and see if it works. When something's wrong, describe the problem clearly. The AI debugs from there.
## How This Played Out for Ironpad
| Phase | What Happened |
|-------|---------------|
| 1-3 | PRD v3.0 with architecture decisions, reviewed by multiple AIs |
| 4 | ai-context.md maintained throughout, Context7 for Axum/Vue/Milkdown docs |
| 5 | Phases 1-3 built with Opus 4.5 (200K), phases 4-5 with Opus 4.6 (1M) |
| 6 | Every feature manually tested in the browser |
Total development time: approximately 2 weeks from PRD to working application with dashboard, calendar, git panel, WYSIWYG editor, subtasks, recurring tasks, and real-time sync.

116
docs/ai-workflow/tools.md Normal file
View File

@@ -0,0 +1,116 @@
# Tools Used
Every tool in the Ironpad development workflow and how it fits in.
## IDE
### Cursor IDE
The primary development environment. Cursor is a fork of VS Code with built-in AI integration.
- **Why:** Direct integration with Claude models, inline code editing, multi-file context
- **How used:** All coding, file editing, terminal commands, and AI conversations happen in Cursor
- **Alternative:** VS Code + Copilot, Windsurf, or any editor with AI integration
## AI Models
### Claude Opus 4.5 (Anthropic)
Used for the majority of Ironpad's development (Phases 1-3 of the implementation).
- **Context window:** 200K tokens
- **Strengths:** Excellent at Rust code, understands Axum patterns well, good at architecture
- **Limitation:** Can only hold ~5 files at once; required task splitting and handover documents
- **How used:** Feature implementation, debugging, code review
### Claude Opus 4.6 (Anthropic)
Used for later phases and the full codebase audit.
- **Context window:** 1M tokens (5x increase)
- **Strengths:** Can hold the entire codebase at once; finds cross-file bugs; handles complex refactors in one session
- **How used:** Codebase audit (found 16 issues), cross-cutting refactors, feature implementation without task splitting
### Perplexity AI
Used for research before coding.
- **Why:** Has internet access, provides current information with citations
- **How used:** Checking library versions, finding known issues with crates, researching approaches
- **Example:** Verified that `serde_yaml` was deprecated before we chose to use it (accepted risk for v1)
### Google Gemini
Used as a second opinion on architecture.
- **Why:** Different training data and perspective from Claude
- **How used:** PRD review, architecture review, catching blind spots
- **Example:** Flagged the need for file locking between Task View and Editor (race condition that Claude's initial design missed)
## MCP Tools
### Task Master
A Model Context Protocol (MCP) tool for structured task management.
- **What it does:** Parses a PRD and generates ordered task lists with dependencies
- **How used:** Fed the PRD to Task Master to generate the implementation plan; tasks tracked through completion
- **Why it matters:** Turns a document into actionable, sequenced work items
### Context7
A Model Context Protocol (MCP) tool for pulling current library documentation.
- **What it does:** Fetches up-to-date documentation for any library and loads it into the AI's context
- **How used:** Pulled current Axum 0.8 docs, Vue 3 Composition API docs, Milkdown editor API docs
- **Why it matters:** Eliminates bugs caused by the AI using outdated API knowledge
## Build Tools
### Rust / Cargo
- Backend language and build system
- `cargo check` for fast compilation checks
- `cargo build --release` for production binaries
- Strict compiler catches entire categories of bugs before runtime
### Node.js / Vite
- Frontend build tooling
- `npm run dev` for development with hot reload
- `npm run build` for production static files
- Vue SFC compilation via `@vitejs/plugin-vue`
### Git
- Version control for all data files
- `git2` crate for programmatic access from Rust
- Automatic 60-second commit batching
- Full diff viewer in the UI
## The Tool Stack in Practice
```
Idea
|
v
[Perplexity] -- Research libraries, check feasibility
[Gemini] -- Second opinion on approach
|
v
[Claude] -- Draft PRD
[Gemini] -- Review PRD
|
v
[Task Master] -- Generate ordered task list
|
v
[Cursor + Claude] -- Implement each task
[Context7] -- Current docs when needed
|
v
[Manual Testing] -- Verify in browser
[cargo check] -- Compiler verification
```
No single tool does everything. The value is in how they compose.

BIN
docs/screenshot.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 214 KiB

24
frontend/.gitignore vendored Normal file
View File

@@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

143
frontend/README.md Normal file
View File

@@ -0,0 +1,143 @@
# Ironpad Frontend
Vue 3 single-page application for Ironpad.
## Quick Start
```bash
npm install
npm run dev
```
Open http://localhost:5173 (requires backend running on port 3000).
## Tech Stack
- **Vue 3** with Composition API and `<script setup>`
- **TypeScript** for type safety
- **Vite** for fast development and builds
- **Pinia** for state management
- **Vue Router** for navigation
- **Milkdown** for WYSIWYG Markdown editing
## Project Structure
```
src/
├── api/ # API client for backend communication
│ └── client.ts
├── components/ # Reusable Vue components
│ ├── MilkdownEditor.vue # Editor wrapper
│ ├── MilkdownEditorCore.vue # Core editor logic
│ ├── Sidebar.vue # Navigation sidebar
│ ├── GitPanel.vue # Git operations panel
│ └── ...
├── composables/ # Vue composables
│ └── useWebSocket.ts
├── router/ # Vue Router configuration
│ └── index.ts
├── stores/ # Pinia stores
│ ├── notes.ts # Notes state
│ ├── projects.ts # Projects state
│ ├── tasks.ts # Tasks state
│ ├── git.ts # Git state
│ ├── theme.ts # Theme state
│ ├── ui.ts # UI state
│ ├── websocket.ts # WebSocket state
│ └── workspace.ts # Workspace state
├── types/ # TypeScript type definitions
│ └── index.ts
├── views/ # Route views
│ ├── DashboardView.vue # Home page with project cards + task summaries
│ ├── ProjectView.vue # Project overview with editor
│ ├── ProjectNotesView.vue # Project notes split view
│ ├── ProjectsView.vue # Projects management list
│ ├── TasksView.vue # Task split view (list + detail)
│ ├── CalendarView.vue # Month grid calendar
│ └── DailyView.vue # Daily notes
├── App.vue # Root component
├── main.ts # Entry point
└── style.css # Global styles
```
## Key Components
### Milkdown Editor
The editor consists of two components:
- **MilkdownEditor.vue** — Wrapper component that accepts a `:key` prop for recreation
- **MilkdownEditorCore.vue** — Core editor using the `@milkdown/vue` integration
**Critical Pattern**: When switching between notes/tasks, content MUST be set BEFORE updating the editor key:
```javascript
// CORRECT order:
editorContent.value = newContent // Set content first
editorKey.value = noteId // Then trigger editor recreation
// WRONG order (causes stale content):
editorKey.value = noteId // Editor recreates with wrong content
editorContent.value = newContent // Too late!
```
### Task System Features
The task view (`TasksView.vue`) includes:
- **Tag system** — tags stored in YAML frontmatter, filterable via tag bar, autocomplete from project tags
- **Subtasks** — tasks with `parent_id` grouped under parents, inline creation, count badges
- **Recurring tasks** — daily/weekly/monthly/yearly, auto-creates next on completion
- **Due date picker** — inline date input, clearable, color-coded urgency display
- **Active/Backlog toggle** — move tasks between states
### Dashboard (`DashboardView.vue`)
Cross-project home page showing all projects as cards with:
- Active task count, backlog count, overdue count
- Top 5 active tasks per project with tags and due dates
- Click-through to project or individual task
### Calendar (`CalendarView.vue`)
Month grid calendar showing:
- Tasks plotted by `due_date` (only tasks with dates appear)
- Daily notes shown as blue dots
- Color-coded urgency (overdue=red, today=red, soon=yellow)
- Navigation: prev/next month, Today button
### State Management
Each domain has its own Pinia store:
- `notesStore` — Standalone notes CRUD
- `projectsStore` — Projects list and details
- `tasksStore` — Project tasks with active/backlog sections, tag filtering, subtask helpers
- `gitStore` — Git status, commits, push/pull
- `themeStore` — Dark/light mode
- `uiStore` — Search panel, modals
- `websocketStore` — Real-time connection state
- `workspaceStore` — Active project tracking
### Auto-save Behavior
Views implement smart auto-save that:
1. Tracks the "last saved content" when a note/task loads
2. Only saves when content differs from last saved
3. Uses 1-second debounce to batch rapid edits
4. Prevents unnecessary saves when just opening items
## Commands
```bash
npm run dev # Start dev server (hot reload)
npm run build # Production build to dist/
npm run preview # Preview production build
npm run lint # Run ESLint
```
## Environment
The frontend expects the backend API at `http://localhost:3000`. This is configured in `src/api/client.ts`.
For production, build the frontend and serve from any static host, configuring the API URL as needed.

38
frontend/index.html Normal file
View File

@@ -0,0 +1,38 @@
<!doctype html>
<html lang="en" data-theme="dark">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
<meta http-equiv="Cache-Control" content="no-cache, no-store, must-revalidate" />
<meta http-equiv="Pragma" content="no-cache" />
<meta http-equiv="Expires" content="0" />
<title>Ironpad</title>
<script>
// Apply saved theme immediately to prevent flash
(function() {
var saved = localStorage.getItem('ironpad-theme');
if (saved === 'light') {
document.documentElement.setAttribute('data-theme', 'light');
}
// Default is dark (already set on html tag)
})();
</script>
<style>
html, body {
margin: 0;
padding: 0;
width: 100%;
height: 100%;
overflow: hidden;
background: #1a1a1a;
}
[data-theme="light"] body {
background: #ffffff;
}
</style>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>

4025
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

37
frontend/package.json Normal file
View File

@@ -0,0 +1,37 @@
{
"name": "frontend",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vue-tsc -b && vite build",
"preview": "vite preview"
},
"dependencies": {
"@codemirror/commands": "^6.10.1",
"@codemirror/lang-markdown": "^6.5.0",
"@codemirror/language": "^6.12.1",
"@codemirror/state": "^6.5.4",
"@codemirror/theme-one-dark": "^6.1.3",
"@codemirror/view": "^6.39.12",
"@milkdown/crepe": "^7.18.0",
"@milkdown/kit": "^7.18.0",
"@milkdown/vue": "^7.18.0",
"@prosemirror-adapter/vue": "^0.4.6",
"codemirror": "^6.0.2",
"markdown-it": "^14.1.0",
"pinia": "^3.0.4",
"vue": "^3.5.24",
"vue-router": "^4.6.4"
},
"devDependencies": {
"@types/markdown-it": "^14.1.2",
"@types/node": "^22.15.0",
"@vitejs/plugin-vue": "^5.2.3",
"@vue/tsconfig": "^0.8.1",
"typescript": "~5.7.3",
"vite": "^6.3.5",
"vue-tsc": "^2.2.10"
}
}

1
frontend/public/vite.svg Normal file
View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

324
frontend/src/App.vue Normal file
View File

@@ -0,0 +1,324 @@
<script setup lang="ts">
import { ref, onMounted, onUnmounted } from 'vue'
import {
useNotesStore,
useProjectsStore,
useTasksStore,
useUiStore,
useGitStore,
useWebSocketStore,
useWorkspaceStore,
useThemeStore
} from './stores'
import { useWebSocket } from './composables/useWebSocket'
import TopBar from './components/TopBar.vue'
import Sidebar from './components/Sidebar.vue'
import ConflictBanner from './components/ConflictBanner.vue'
const notesStore = useNotesStore()
const projectsStore = useProjectsStore()
const tasksStore = useTasksStore()
const uiStore = useUiStore()
const gitStore = useGitStore()
const wsStore = useWebSocketStore()
const workspaceStore = useWorkspaceStore()
const themeStore = useThemeStore()
// Non-blocking external edit notification (replaces blocking confirm())
const externalEditPath = ref<string | null>(null)
function reloadExternalEdit() {
if (notesStore.currentNote && externalEditPath.value) {
notesStore.loadNote(notesStore.currentNote.id)
}
externalEditPath.value = null
}
function dismissExternalEdit() {
externalEditPath.value = null
}
// Initialize theme immediately (before mount for no flash)
themeStore.init()
// WebSocket connection with handlers
const { connected, clientId } = useWebSocket({
onFileCreated: () => {
notesStore.loadNotes()
projectsStore.loadProjects()
},
onFileModified: (path) => {
notesStore.loadNotes()
// Non-blocking notification if current note was modified externally
if (notesStore.currentNote?.path === path) {
externalEditPath.value = path
}
},
onFileDeleted: () => {
notesStore.loadNotes()
projectsStore.loadProjects()
},
onFileLocked: (path, lockClientId, lockType) => {
wsStore.addFileLock({
path,
client_id: lockClientId,
lock_type: lockType as 'editor' | 'task_view'
})
},
onFileUnlocked: (path) => {
wsStore.removeFileLock(path)
},
onGitConflict: (files) => {
wsStore.setGitConflicts(files)
}
})
// Sync WebSocket state to store
import { watch } from 'vue'
// Note: watch imported separately to maintain original code structure
watch(connected, (val) => wsStore.setConnected(val))
watch(clientId, (val) => wsStore.setClientId(val))
// Keyboard shortcuts
function handleKeydown(e: KeyboardEvent) {
// Ctrl/Cmd + K for search
if ((e.ctrlKey || e.metaKey) && e.key === 'k') {
e.preventDefault()
uiStore.toggleSearch()
}
// Ctrl/Cmd + S to save
if ((e.ctrlKey || e.metaKey) && e.key === 's') {
e.preventDefault()
if (notesStore.currentNote) {
// Trigger save - the view will handle it
const event = new CustomEvent('save-note')
window.dispatchEvent(event)
}
}
// Escape to close panels
if (e.key === 'Escape') {
uiStore.closeSearch()
uiStore.closeTasks()
}
}
onMounted(async () => {
// Load initial data
await Promise.all([
notesStore.loadNotes(),
projectsStore.loadProjects(),
tasksStore.loadAllTasks(),
gitStore.loadStatus(),
gitStore.loadRemote()
])
// Load saved active project
await workspaceStore.loadSavedProject()
// Check for git conflicts and remote status periodically
setInterval(() => {
gitStore.checkConflicts()
gitStore.loadRemote()
}, 60000)
window.addEventListener('keydown', handleKeydown)
})
onUnmounted(() => {
window.removeEventListener('keydown', handleKeydown)
})
</script>
<template>
<div id="app-layout">
<TopBar />
<div id="app-container">
<Sidebar />
<main class="main">
<ConflictBanner />
<div v-if="externalEditPath" class="external-edit-banner">
File modified externally.
<button @click="reloadExternalEdit" class="primary" style="margin-left: 8px">Reload</button>
<button @click="dismissExternalEdit" style="margin-left: 4px">Dismiss</button>
</div>
<div v-if="uiStore.globalError" class="error-message">
{{ uiStore.globalError }}
<button @click="uiStore.clearGlobalError" style="margin-left: 12px">Dismiss</button>
</div>
<router-view />
</main>
</div>
</div>
</template>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
html, body {
width: 100%;
height: 100%;
margin: 0;
padding: 0;
overflow: hidden;
}
:root {
--sidebar-width: 280px;
--header-height: 52px;
--font-mono: 'SF Mono', 'Monaco', 'Menlo', 'Consolas', monospace;
/* Light theme as base (will be overridden by dark) */
--color-bg: #ffffff;
--color-bg-secondary: #f8f9fa;
--color-bg-hover: #f1f3f5;
--color-border: #e1e4e8;
--color-text: #24292e;
--color-text-secondary: #586069;
--color-primary: #0366d6;
--color-danger: #cb2431;
--color-success: #28a745;
--color-warning: #f0ad4e;
}
/* Light theme explicit */
[data-theme="light"] {
--color-bg: #ffffff;
--color-bg-secondary: #f8f9fa;
--color-bg-hover: #f1f3f5;
--color-border: #e1e4e8;
--color-text: #24292e;
--color-text-secondary: #586069;
--color-primary: #0366d6;
--color-danger: #cb2431;
--color-success: #28a745;
--color-warning: #f0ad4e;
}
/* Dark theme - overrides light when data-theme="dark" */
[data-theme="dark"] {
--color-bg: #1a1a1a;
--color-bg-secondary: #232323;
--color-bg-hover: #2d2d2d;
--color-border: #3c3c3c;
--color-text: #e0e0e0;
--color-text-secondary: #999999;
--color-primary: #58a6ff;
--color-danger: #f85149;
--color-success: #56d364;
--color-warning: #d29922;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif;
font-size: 14px;
line-height: 1.5;
color: var(--color-text);
background: var(--color-bg);
}
#app {
width: 100%;
height: 100vh;
overflow: hidden;
}
#app-layout {
display: flex;
flex-direction: column;
width: 100%;
height: 100%;
overflow: hidden;
}
#app-container {
display: flex;
flex: 1;
overflow: hidden;
min-width: 0;
}
.main {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
min-width: 0;
}
.external-edit-banner {
padding: 10px 16px;
background: var(--color-warning);
color: #1a1a1a;
font-size: 13px;
display: flex;
align-items: center;
}
.error-message {
padding: 12px 16px;
background: var(--color-danger);
color: white;
font-size: 13px;
}
button {
padding: 6px 12px;
border: 1px solid var(--color-border);
border-radius: 6px;
background: var(--color-bg);
color: var(--color-text);
font-size: 13px;
cursor: pointer;
transition: background 0.15s, border-color 0.15s;
}
button:hover {
background: var(--color-bg-secondary);
border-color: var(--color-text-secondary);
}
button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
button.primary {
background: var(--color-primary);
border-color: var(--color-primary);
color: white;
}
button.primary:hover {
opacity: 0.9;
}
button.danger {
color: var(--color-danger);
}
button.danger:hover {
background: var(--color-danger);
border-color: var(--color-danger);
color: white;
}
input[type="text"],
input[type="search"] {
padding: 8px 12px;
border: 1px solid var(--color-border);
border-radius: 6px;
background: var(--color-bg);
color: var(--color-text);
font-size: 14px;
outline: none;
}
input[type="text"]:focus,
input[type="search"]:focus {
border-color: var(--color-primary);
}
</style>

240
frontend/src/api/client.ts Normal file
View File

@@ -0,0 +1,240 @@
// API client for Ironpad backend
import type {
Note,
NoteSummary,
Project,
ProjectWithContent,
ProjectNote,
ProjectNoteWithContent,
Task,
TaskWithContent,
SearchResult,
GitStatus,
CommitInfo,
CommitDetail,
DiffInfo,
RemoteInfo,
DailyNote
} from '../types'
const API_BASE = '/api'
async function request<T>(url: string, options?: RequestInit): Promise<T> {
const res = await fetch(`${API_BASE}${url}`, options)
if (!res.ok) {
const text = await res.text()
throw new Error(text || `HTTP ${res.status}`)
}
// Handle empty responses
const contentType = res.headers.get('content-type')
if (contentType?.includes('application/json')) {
return res.json()
}
return undefined as T
}
// Notes API
export const notesApi = {
list: () => request<NoteSummary[]>('/notes'),
get: (id: string) => request<Note>(`/notes/${encodeURIComponent(id)}`),
create: () => request<Note>('/notes', { method: 'POST' }),
update: (id: string, content: string) =>
request<Note>(`/notes/${encodeURIComponent(id)}`, {
method: 'PUT',
headers: { 'Content-Type': 'text/plain' },
body: content
}),
delete: (id: string) =>
request<void>(`/notes/${encodeURIComponent(id)}`, { method: 'DELETE' })
}
// Projects API
export const projectsApi = {
list: () => request<Project[]>('/projects'),
get: (id: string) => request<Project>(`/projects/${encodeURIComponent(id)}`),
getContent: (id: string) =>
request<ProjectWithContent>(`/projects/${encodeURIComponent(id)}/content`),
updateContent: (id: string, content: string) =>
request<ProjectWithContent>(`/projects/${encodeURIComponent(id)}/content`, {
method: 'PUT',
headers: { 'Content-Type': 'text/plain' },
body: content
}),
create: (name: string) =>
request<Project>('/projects', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name })
}),
// Project Notes
listNotes: (projectId: string) =>
request<ProjectNote[]>(`/projects/${encodeURIComponent(projectId)}/notes`),
getNote: (projectId: string, noteId: string) =>
request<ProjectNoteWithContent>(`/projects/${encodeURIComponent(projectId)}/notes/${encodeURIComponent(noteId)}`),
createNote: (projectId: string, title?: string) =>
request<ProjectNoteWithContent>(`/projects/${encodeURIComponent(projectId)}/notes`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ title })
}),
updateNote: (projectId: string, noteId: string, content: string) =>
request<ProjectNoteWithContent>(`/projects/${encodeURIComponent(projectId)}/notes/${encodeURIComponent(noteId)}`, {
method: 'PUT',
headers: { 'Content-Type': 'text/plain' },
body: content
}),
deleteNote: (projectId: string, noteId: string) =>
request<void>(`/projects/${encodeURIComponent(projectId)}/notes/${encodeURIComponent(noteId)}`, {
method: 'DELETE'
})
}
// Tasks API (file-based tasks)
export const tasksApi = {
// List all tasks across all projects
listAll: () => request<Task[]>('/tasks'),
// List tasks for a specific project
list: (projectId: string) =>
request<Task[]>(`/projects/${encodeURIComponent(projectId)}/tasks`),
// Get a single task with content
get: (projectId: string, taskId: string) =>
request<TaskWithContent>(`/projects/${encodeURIComponent(projectId)}/tasks/${encodeURIComponent(taskId)}`),
// Create a new task
create: (projectId: string, title: string, section?: string, parentId?: string) =>
request<TaskWithContent>(`/projects/${encodeURIComponent(projectId)}/tasks`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ title, section, parent_id: parentId || undefined })
}),
// Update task content (markdown body)
updateContent: (projectId: string, taskId: string, content: string) =>
request<TaskWithContent>(`/projects/${encodeURIComponent(projectId)}/tasks/${encodeURIComponent(taskId)}`, {
method: 'PUT',
headers: { 'Content-Type': 'text/plain' },
body: content
}),
// Toggle task completion
toggle: (projectId: string, taskId: string) =>
request<Task>(`/projects/${encodeURIComponent(projectId)}/tasks/${encodeURIComponent(taskId)}/toggle`, {
method: 'PUT'
}),
// Update task metadata
updateMeta: (projectId: string, taskId: string, meta: { title?: string; section?: string; priority?: string; due_date?: string; is_active?: boolean; tags?: string[]; recurrence?: string; recurrence_interval?: number }) =>
request<Task>(`/projects/${encodeURIComponent(projectId)}/tasks/${encodeURIComponent(taskId)}/meta`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(meta)
}),
// Delete (archive) a task
delete: (projectId: string, taskId: string) =>
request<void>(`/projects/${encodeURIComponent(projectId)}/tasks/${encodeURIComponent(taskId)}`, {
method: 'DELETE'
})
}
// Search API
export const searchApi = {
search: (query: string) =>
request<SearchResult[]>(`/search?q=${encodeURIComponent(query)}`)
}
// Git API
export const gitApi = {
status: () => request<GitStatus>('/git/status'),
commit: (message?: string) =>
request<CommitInfo>('/git/commit', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ message })
}),
push: () => request<{ success: boolean; message: string }>('/git/push', { method: 'POST' }),
conflicts: () => request<string[]>('/git/conflicts'),
// Commit history
log: (limit?: number) =>
request<CommitDetail[]>(`/git/log${limit ? `?limit=${limit}` : ''}`),
// Working directory diff (uncommitted changes)
diff: () => request<DiffInfo>('/git/diff'),
// Diff for a specific commit
commitDiff: (commitId: string) =>
request<DiffInfo>(`/git/diff/${encodeURIComponent(commitId)}`),
// Remote repository info
remote: () => request<RemoteInfo | null>('/git/remote'),
// Fetch from remote
fetch: () => request<{ success: boolean; message: string }>('/git/fetch', { method: 'POST' })
}
// Daily Notes API
export const dailyApi = {
list: () => request<DailyNote[]>('/daily'),
today: () => request<DailyNote>('/daily/today'),
get: (date: string) => request<DailyNote>(`/daily/${date}`),
create: (date: string, content?: string) =>
request<DailyNote>(`/daily/${date}`, {
method: 'POST',
headers: content ? { 'Content-Type': 'application/json' } : undefined,
body: content ? JSON.stringify({ content }) : undefined
}),
update: (date: string, content: string) =>
request<DailyNote>(`/daily/${date}`, {
method: 'PUT',
headers: { 'Content-Type': 'text/plain' },
body: content
})
}
// Assets API
export const assetsApi = {
upload: async (file: File, projectId?: string): Promise<{ url: string; filename: string }> => {
const formData = new FormData()
formData.append('file', file)
const params = projectId ? `?project=${encodeURIComponent(projectId)}` : ''
const res = await fetch(`${API_BASE}/assets/upload${params}`, {
method: 'POST',
body: formData
})
if (!res.ok) {
const text = await res.text()
throw new Error(text || `HTTP ${res.status}`)
}
return res.json()
},
getUrl: (project: string, filename: string) =>
`${API_BASE}/assets/${encodeURIComponent(project)}/${encodeURIComponent(filename)}`
}

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="37.07" height="36" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 198"><path fill="#41B883" d="M204.8 0H256L128 220.8L0 0h97.92L128 51.2L157.44 0h47.36Z"></path><path fill="#41B883" d="m0 0l128 220.8L256 0h-51.2L128 132.48L50.56 0H0Z"></path><path fill="#35495E" d="M50.56 0L128 133.12L204.8 0h-47.36L128 51.2L97.92 0H50.56Z"></path></svg>

After

Width:  |  Height:  |  Size: 496 B

View File

@@ -0,0 +1,65 @@
<script setup lang="ts">
import { useGitStore } from '../stores'
const gitStore = useGitStore()
</script>
<template>
<div v-if="gitStore.hasConflicts" class="conflict-banner">
<span class="icon"></span>
<span class="message">
Git conflicts detected in {{ gitStore.conflicts.length }} file(s).
Please resolve conflicts manually using git or an external tool.
</span>
<details class="conflict-files">
<summary>Show files</summary>
<ul>
<li v-for="file in gitStore.conflicts" :key="file">{{ file }}</li>
</ul>
</details>
</div>
</template>
<style scoped>
.conflict-banner {
padding: 12px 16px;
background: var(--color-danger);
color: white;
font-size: 13px;
display: flex;
align-items: flex-start;
gap: 8px;
flex-wrap: wrap;
}
.icon {
font-size: 14px;
flex-shrink: 0;
}
.message {
flex: 1;
min-width: 200px;
}
.conflict-files {
width: 100%;
margin-top: 8px;
}
.conflict-files summary {
cursor: pointer;
font-weight: 500;
}
.conflict-files ul {
margin: 8px 0 0 0;
padding-left: 20px;
}
.conflict-files li {
font-family: monospace;
font-size: 12px;
margin: 4px 0;
}
</style>

View File

@@ -0,0 +1,322 @@
<script setup lang="ts">
import { ref } from 'vue'
const emit = defineEmits<{
'format': [type: string, extra?: string]
'insert-image': [file: File]
'insert-link': []
}>()
const fileInput = ref<HTMLInputElement | null>(null)
// Formatting actions
function bold() { emit('format', 'bold') }
function italic() { emit('format', 'italic') }
function strikethrough() { emit('format', 'strikethrough') }
function heading(level: number) { emit('format', 'heading', String(level)) }
function link() { emit('insert-link') }
function code() { emit('format', 'code') }
function codeBlock() { emit('format', 'codeblock') }
function quote() { emit('format', 'quote') }
function bulletList() { emit('format', 'bullet') }
function numberedList() { emit('format', 'numbered') }
function taskList() { emit('format', 'task') }
function horizontalRule() { emit('format', 'hr') }
// Image handling
function triggerImageUpload() {
fileInput.value?.click()
}
function handleFileSelect(event: Event) {
const input = event.target as HTMLInputElement
const file = input.files?.[0]
if (file) {
emit('insert-image', file)
// Reset input so same file can be selected again
input.value = ''
}
}
// Heading dropdown
const showHeadingDropdown = ref(false)
function toggleHeadingDropdown() {
showHeadingDropdown.value = !showHeadingDropdown.value
}
function selectHeading(level: number) {
heading(level)
showHeadingDropdown.value = false
}
// Close dropdown when clicking outside
function closeDropdowns() {
showHeadingDropdown.value = false
}
</script>
<template>
<div class="editor-toolbar" @click.stop>
<!-- Hidden file input for image upload -->
<input
ref="fileInput"
type="file"
accept="image/*"
style="display: none"
@change="handleFileSelect"
/>
<!-- Text formatting group -->
<div class="toolbar-group">
<button
class="toolbar-btn"
@click="bold"
title="Bold (Ctrl+B)"
>
<span class="icon">B</span>
</button>
<button
class="toolbar-btn"
@click="italic"
title="Italic (Ctrl+I)"
>
<span class="icon italic">I</span>
</button>
<button
class="toolbar-btn"
@click="strikethrough"
title="Strikethrough"
>
<span class="icon strikethrough">S</span>
</button>
<button
class="toolbar-btn"
@click="code"
title="Inline code (Ctrl+`)"
>
<span class="icon mono">&lt;/&gt;</span>
</button>
</div>
<div class="toolbar-divider"></div>
<!-- Heading dropdown -->
<div class="toolbar-group">
<div class="dropdown-container">
<button
class="toolbar-btn"
@click="toggleHeadingDropdown"
title="Heading"
>
<span class="icon">H</span>
<span class="dropdown-arrow"></span>
</button>
<div v-if="showHeadingDropdown" class="dropdown-menu" @click.stop>
<button @click="selectHeading(1)">Heading 1</button>
<button @click="selectHeading(2)">Heading 2</button>
<button @click="selectHeading(3)">Heading 3</button>
<button @click="selectHeading(4)">Heading 4</button>
</div>
</div>
</div>
<div class="toolbar-divider"></div>
<!-- Insert group -->
<div class="toolbar-group">
<button
class="toolbar-btn"
@click="link"
title="Insert link"
>
<span class="icon">🔗</span>
</button>
<button
class="toolbar-btn"
@click="triggerImageUpload"
title="Insert image"
>
<span class="icon">🖼</span>
</button>
<button
class="toolbar-btn"
@click="codeBlock"
title="Code block"
>
<span class="icon mono">{}</span>
</button>
</div>
<div class="toolbar-divider"></div>
<!-- List group -->
<div class="toolbar-group">
<button
class="toolbar-btn"
@click="bulletList"
title="Bullet list"
>
<span class="icon"></span>
</button>
<button
class="toolbar-btn"
@click="numberedList"
title="Numbered list"
>
<span class="icon">1.</span>
</button>
<button
class="toolbar-btn"
@click="taskList"
title="Task list"
>
<span class="icon"></span>
</button>
</div>
<div class="toolbar-divider"></div>
<!-- Block group -->
<div class="toolbar-group">
<button
class="toolbar-btn"
@click="quote"
title="Quote"
>
<span class="icon">"</span>
</button>
<button
class="toolbar-btn"
@click="horizontalRule"
title="Horizontal rule"
>
<span class="icon">—</span>
</button>
</div>
<!-- Click outside to close dropdowns -->
<div
v-if="showHeadingDropdown"
class="dropdown-overlay"
@click="closeDropdowns"
></div>
</div>
</template>
<style scoped>
.editor-toolbar {
display: flex;
align-items: center;
gap: 4px;
padding: 8px 12px;
background: var(--color-bg-secondary);
border-bottom: 1px solid var(--color-border);
flex-wrap: wrap;
}
.toolbar-group {
display: flex;
align-items: center;
gap: 2px;
}
.toolbar-btn {
display: flex;
align-items: center;
justify-content: center;
width: 32px;
height: 28px;
padding: 0;
border: none;
border-radius: 4px;
background: transparent;
color: var(--color-text);
cursor: pointer;
transition: background 0.15s, color 0.15s;
}
.toolbar-btn:hover {
background: var(--color-bg-hover);
}
.toolbar-btn:active {
background: var(--color-border);
}
.toolbar-btn .icon {
font-size: 14px;
font-weight: 600;
}
.toolbar-btn .icon.italic {
font-style: italic;
}
.toolbar-btn .icon.strikethrough {
text-decoration: line-through;
}
.toolbar-btn .icon.mono {
font-family: var(--font-mono);
font-size: 11px;
font-weight: 500;
}
.toolbar-btn .dropdown-arrow {
font-size: 8px;
margin-left: 2px;
color: var(--color-text-secondary);
}
.toolbar-divider {
width: 1px;
height: 20px;
background: var(--color-border);
margin: 0 6px;
}
/* Dropdown */
.dropdown-container {
position: relative;
}
.dropdown-menu {
position: absolute;
top: 100%;
left: 0;
margin-top: 4px;
background: var(--color-bg);
border: 1px solid var(--color-border);
border-radius: 6px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
z-index: 100;
min-width: 120px;
overflow: hidden;
}
.dropdown-menu button {
display: block;
width: 100%;
padding: 8px 12px;
border: none;
background: transparent;
color: var(--color-text);
text-align: left;
cursor: pointer;
font-size: 13px;
}
.dropdown-menu button:hover {
background: var(--color-bg-hover);
}
.dropdown-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
z-index: 99;
}
</style>

View File

@@ -0,0 +1,879 @@
<script setup lang="ts">
import { ref, computed, watch } from 'vue'
import { useGitStore } from '../stores'
const gitStore = useGitStore()
// Local state
const commitMessage = ref('')
const activeTab = ref<'changes' | 'history'>('changes')
const expandedFiles = ref<Set<string>>(new Set())
// Computed
const canCommit = computed(() =>
gitStore.hasChanges && commitMessage.value.trim().length > 0
)
const remoteDisplay = computed(() => {
if (!gitStore.remote) return null
const url = gitStore.remote.url
// Extract repo name from URL (handles both https and ssh)
const match = url.match(/[:/]([^/]+\/[^/.]+)(?:\.git)?$/)
return match ? match[1] : url
})
// Actions
async function doCommit() {
if (!canCommit.value) return
const result = await gitStore.commit(commitMessage.value.trim())
if (result) {
commitMessage.value = ''
}
}
async function doPush() {
try {
await gitStore.push()
} catch {
// Error handled in store
}
}
async function doFetch() {
try {
await gitStore.fetchRemote()
} catch {
// Error handled in store
}
}
function selectCommit(commitId: string) {
if (gitStore.selectedCommitId === commitId) {
gitStore.clearSelectedCommit()
} else {
gitStore.loadCommitDiff(commitId)
}
}
function toggleFile(path: string) {
if (expandedFiles.value.has(path)) {
expandedFiles.value.delete(path)
} else {
expandedFiles.value.add(path)
}
}
function formatTimestamp(timestamp: string): string {
const date = new Date(timestamp)
const now = new Date()
const diffMs = now.getTime() - date.getTime()
const diffMins = Math.floor(diffMs / 60000)
const diffHours = Math.floor(diffMs / 3600000)
const diffDays = Math.floor(diffMs / 86400000)
if (diffMins < 1) return 'just now'
if (diffMins < 60) return `${diffMins}m ago`
if (diffHours < 24) return `${diffHours}h ago`
if (diffDays < 7) return `${diffDays}d ago`
return date.toLocaleDateString()
}
function getStatusIcon(status: string): string {
switch (status) {
case 'new':
case 'added': return '+'
case 'modified': return '~'
case 'deleted': return '-'
case 'renamed': return '→'
default: return '?'
}
}
function getStatusClass(status: string): string {
switch (status) {
case 'new':
case 'added': return 'status-added'
case 'modified': return 'status-modified'
case 'deleted': return 'status-deleted'
case 'renamed': return 'status-renamed'
default: return ''
}
}
// Close panel on escape
function handleKeydown(e: KeyboardEvent) {
if (e.key === 'Escape') {
gitStore.togglePanel()
}
}
// Watch for panel open to load data
watch(() => gitStore.panelOpen, (open) => {
if (open) {
document.addEventListener('keydown', handleKeydown)
} else {
document.removeEventListener('keydown', handleKeydown)
gitStore.clearSelectedCommit()
}
})
</script>
<template>
<Teleport to="body">
<Transition name="panel">
<div v-if="gitStore.panelOpen" class="git-panel-overlay" @click.self="gitStore.togglePanel()">
<div class="git-panel">
<!-- Header -->
<header class="panel-header">
<div class="header-left">
<span class="branch-icon"></span>
<span class="branch-name">{{ gitStore.branch }}</span>
<span v-if="gitStore.hasChanges" class="changes-badge">
{{ gitStore.changedFilesCount }}
</span>
</div>
<div class="header-right">
<div v-if="gitStore.remote" class="remote-info">
<span class="remote-name" :title="gitStore.remote.url">{{ remoteDisplay }}</span>
<span v-if="gitStore.remote.ahead > 0" class="ahead">{{ gitStore.remote.ahead }}</span>
<span v-if="gitStore.remote.behind > 0" class="behind">{{ gitStore.remote.behind }}</span>
</div>
<button class="close-btn" @click="gitStore.togglePanel()" title="Close (Esc)">×</button>
</div>
</header>
<!-- Error Banner -->
<div v-if="gitStore.error" class="error-banner">
{{ gitStore.error }}
<button @click="gitStore.clearError()">×</button>
</div>
<!-- Tabs -->
<div class="tabs">
<button
:class="['tab', { active: activeTab === 'changes' }]"
@click="activeTab = 'changes'"
>
Changes
<span v-if="gitStore.hasChanges" class="tab-badge">{{ gitStore.changedFilesCount }}</span>
</button>
<button
:class="['tab', { active: activeTab === 'history' }]"
@click="activeTab = 'history'"
>
History
</button>
</div>
<!-- Content -->
<div class="panel-content">
<!-- Changes Tab -->
<div v-if="activeTab === 'changes'" class="changes-tab">
<!-- Commit Form -->
<div class="commit-form">
<textarea
v-model="commitMessage"
placeholder="Commit message..."
class="commit-input"
rows="2"
@keydown.ctrl.enter="doCommit"
></textarea>
<div class="commit-actions">
<button
class="commit-btn"
:disabled="!canCommit || gitStore.committing"
@click="doCommit"
>
{{ gitStore.committing ? 'Committing...' : 'Commit' }}
</button>
<button
v-if="gitStore.hasRemote"
class="push-btn"
:disabled="gitStore.pushing"
@click="doPush"
:title="gitStore.canPush ? `Push ${gitStore.remote?.ahead} commits` : 'Push to remote'"
>
{{ gitStore.pushing ? '...' : '↑ Push' }}
</button>
<button
v-if="gitStore.hasRemote"
class="fetch-btn"
:disabled="gitStore.fetching"
@click="doFetch"
title="Fetch from remote"
>
{{ gitStore.fetching ? '...' : '↓ Fetch' }}
</button>
</div>
</div>
<!-- Changed Files List -->
<div v-if="gitStore.hasChanges" class="files-list">
<div class="section-header">
<span>Staged Changes</span>
<span class="stats" v-if="gitStore.workingDiff">
<span class="insertions">+{{ gitStore.workingDiff.stats.insertions }}</span>
<span class="deletions">-{{ gitStore.workingDiff.stats.deletions }}</span>
</span>
</div>
<div v-if="gitStore.diffLoading && !gitStore.workingDiff" class="loading">
Loading diff...
</div>
<div v-else-if="gitStore.workingDiff" class="diff-files">
<div
v-for="file in gitStore.workingDiff.files"
:key="file.path"
class="diff-file"
>
<div
class="file-header"
@click="toggleFile(file.path)"
>
<span :class="['status-icon', getStatusClass(file.status)]">
{{ getStatusIcon(file.status) }}
</span>
<span class="file-path">{{ file.path }}</span>
<span class="file-stats">
<span v-if="file.additions" class="insertions">+{{ file.additions }}</span>
<span v-if="file.deletions" class="deletions">-{{ file.deletions }}</span>
</span>
<span class="expand-icon">{{ expandedFiles.has(file.path) ? '▼' : '▶' }}</span>
</div>
<div v-if="expandedFiles.has(file.path)" class="file-diff">
<div v-for="(hunk, i) in file.hunks" :key="i" class="diff-hunk">
<div class="hunk-header">{{ hunk.header }}</div>
<div class="diff-lines">
<div
v-for="(line, j) in hunk.lines"
:key="j"
:class="['diff-line', {
'line-add': line.origin === '+',
'line-del': line.origin === '-',
'line-ctx': line.origin === ' '
}]"
>
<span class="line-origin">{{ line.origin }}</span>
<span class="line-content">{{ line.content }}</span>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<div v-else class="no-changes">
<span class="icon"></span>
<span>No changes</span>
</div>
</div>
<!-- History Tab -->
<div v-if="activeTab === 'history'" class="history-tab">
<div v-if="gitStore.historyLoading && !gitStore.history.length" class="loading">
Loading history...
</div>
<div v-else-if="gitStore.history.length === 0" class="no-history">
No commits yet
</div>
<div v-else class="commit-list">
<div
v-for="commit in gitStore.history"
:key="commit.id"
:class="['commit-item', { selected: gitStore.selectedCommitId === commit.id }]"
@click="selectCommit(commit.id)"
>
<div class="commit-header">
<span class="commit-id">{{ commit.short_id }}</span>
<span class="commit-time">{{ formatTimestamp(commit.timestamp) }}</span>
</div>
<div class="commit-message">{{ commit.message }}</div>
<div class="commit-meta">
<span class="commit-author">{{ commit.author }}</span>
<span v-if="commit.files_changed" class="commit-files">
{{ commit.files_changed }} file{{ commit.files_changed !== 1 ? 's' : '' }}
</span>
</div>
<!-- Commit Diff (expanded) -->
<div
v-if="gitStore.selectedCommitId === commit.id && gitStore.selectedCommitDiff"
class="commit-diff"
@click.stop
>
<div
v-for="file in gitStore.selectedCommitDiff.files"
:key="file.path"
class="diff-file"
>
<div
class="file-header"
@click="toggleFile(`${commit.id}:${file.path}`)"
>
<span :class="['status-icon', getStatusClass(file.status)]">
{{ getStatusIcon(file.status) }}
</span>
<span class="file-path">{{ file.path }}</span>
<span class="file-stats">
<span v-if="file.additions" class="insertions">+{{ file.additions }}</span>
<span v-if="file.deletions" class="deletions">-{{ file.deletions }}</span>
</span>
<span class="expand-icon">
{{ expandedFiles.has(`${commit.id}:${file.path}`) ? '▼' : '▶' }}
</span>
</div>
<div v-if="expandedFiles.has(`${commit.id}:${file.path}`)" class="file-diff">
<div v-for="(hunk, i) in file.hunks" :key="i" class="diff-hunk">
<div class="hunk-header">{{ hunk.header }}</div>
<div class="diff-lines">
<div
v-for="(line, j) in hunk.lines"
:key="j"
:class="['diff-line', {
'line-add': line.origin === '+',
'line-del': line.origin === '-',
'line-ctx': line.origin === ' '
}]"
>
<span class="line-origin">{{ line.origin }}</span>
<span class="line-content">{{ line.content }}</span>
</div>
</div>
</div>
</div>
</div>
</div>
<div
v-else-if="gitStore.selectedCommitId === commit.id && gitStore.diffLoading"
class="loading"
>
Loading diff...
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</Transition>
</Teleport>
</template>
<style scoped>
/* Panel Overlay */
.git-panel-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.3);
z-index: 1000;
display: flex;
justify-content: flex-end;
}
/* Panel */
.git-panel {
width: 480px;
max-width: 100%;
height: 100%;
background: var(--color-bg);
display: flex;
flex-direction: column;
box-shadow: -4px 0 20px rgba(0, 0, 0, 0.2);
}
/* Transitions */
.panel-enter-active,
.panel-leave-active {
transition: opacity 0.2s ease;
}
.panel-enter-active .git-panel,
.panel-leave-active .git-panel {
transition: transform 0.2s ease;
}
.panel-enter-from,
.panel-leave-to {
opacity: 0;
}
.panel-enter-from .git-panel,
.panel-leave-to .git-panel {
transform: translateX(100%);
}
/* Header */
.panel-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 12px 16px;
border-bottom: 1px solid var(--color-border);
background: var(--color-bg-secondary);
}
.header-left {
display: flex;
align-items: center;
gap: 8px;
}
.branch-icon {
font-size: 14px;
color: var(--color-text-secondary);
}
.branch-name {
font-weight: 600;
font-size: 14px;
}
.changes-badge {
background: var(--color-primary);
color: white;
font-size: 11px;
padding: 1px 6px;
border-radius: 10px;
}
.header-right {
display: flex;
align-items: center;
gap: 12px;
}
.remote-info {
display: flex;
align-items: center;
gap: 6px;
font-size: 12px;
color: var(--color-text-secondary);
}
.remote-name {
max-width: 150px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.ahead {
color: var(--color-success);
}
.behind {
color: var(--color-warning);
}
.close-btn {
background: none;
border: none;
font-size: 20px;
color: var(--color-text-secondary);
cursor: pointer;
padding: 0 4px;
}
.close-btn:hover {
color: var(--color-text);
}
/* Error Banner */
.error-banner {
display: flex;
justify-content: space-between;
align-items: center;
padding: 8px 16px;
background: var(--color-danger);
color: white;
font-size: 13px;
}
.error-banner button {
background: none;
border: none;
color: white;
cursor: pointer;
font-size: 16px;
}
/* Tabs */
.tabs {
display: flex;
border-bottom: 1px solid var(--color-border);
}
.tab {
flex: 1;
padding: 10px 16px;
background: none;
border: none;
border-bottom: 2px solid transparent;
cursor: pointer;
font-size: 13px;
color: var(--color-text-secondary);
display: flex;
align-items: center;
justify-content: center;
gap: 6px;
}
.tab:hover {
color: var(--color-text);
background: var(--color-bg-hover);
}
.tab.active {
color: var(--color-primary);
border-bottom-color: var(--color-primary);
}
.tab-badge {
background: var(--color-primary);
color: white;
font-size: 10px;
padding: 1px 5px;
border-radius: 8px;
}
/* Content */
.panel-content {
flex: 1;
overflow-y: auto;
}
/* Changes Tab */
.changes-tab {
padding: 16px;
}
.commit-form {
margin-bottom: 16px;
}
.commit-input {
width: 100%;
padding: 10px 12px;
border: 1px solid var(--color-border);
border-radius: 6px;
background: var(--color-bg);
color: var(--color-text);
font-family: inherit;
font-size: 13px;
resize: vertical;
min-height: 60px;
}
.commit-input:focus {
outline: none;
border-color: var(--color-primary);
}
.commit-input::placeholder {
color: var(--color-text-secondary);
}
.commit-actions {
display: flex;
gap: 8px;
margin-top: 8px;
}
.commit-btn,
.push-btn,
.fetch-btn {
padding: 8px 16px;
border: none;
border-radius: 6px;
cursor: pointer;
font-size: 13px;
font-weight: 500;
}
.commit-btn {
flex: 1;
background: var(--color-primary);
color: white;
}
.commit-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.push-btn,
.fetch-btn {
background: var(--color-bg-secondary);
color: var(--color-text);
border: 1px solid var(--color-border);
}
.push-btn:hover:not(:disabled),
.fetch-btn:hover:not(:disabled) {
background: var(--color-bg-hover);
}
.push-btn:disabled,
.fetch-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
/* Files List */
.files-list {
border-top: 1px solid var(--color-border);
padding-top: 16px;
}
.section-header {
display: flex;
justify-content: space-between;
align-items: center;
font-size: 12px;
font-weight: 600;
color: var(--color-text-secondary);
text-transform: uppercase;
letter-spacing: 0.5px;
margin-bottom: 8px;
}
.stats {
font-weight: normal;
display: flex;
gap: 8px;
}
.insertions {
color: var(--color-success);
}
.deletions {
color: var(--color-danger);
}
/* Diff Files */
.diff-files {
display: flex;
flex-direction: column;
gap: 4px;
}
.diff-file {
border: 1px solid var(--color-border);
border-radius: 6px;
overflow: hidden;
}
.file-header {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 12px;
background: var(--color-bg-secondary);
cursor: pointer;
font-size: 13px;
}
.file-header:hover {
background: var(--color-bg-hover);
}
.status-icon {
font-family: monospace;
font-weight: bold;
width: 16px;
text-align: center;
}
.status-added { color: var(--color-success); }
.status-modified { color: var(--color-warning); }
.status-deleted { color: var(--color-danger); }
.status-renamed { color: var(--color-primary); }
.file-path {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-family: var(--font-mono);
font-size: 12px;
}
.file-stats {
display: flex;
gap: 6px;
font-size: 11px;
font-family: var(--font-mono);
}
.expand-icon {
font-size: 10px;
color: var(--color-text-secondary);
}
/* File Diff Content */
.file-diff {
border-top: 1px solid var(--color-border);
max-height: 300px;
overflow-y: auto;
}
.diff-hunk {
font-family: var(--font-mono);
font-size: 11px;
}
.hunk-header {
padding: 4px 12px;
background: var(--color-bg-secondary);
color: var(--color-text-secondary);
border-bottom: 1px solid var(--color-border);
}
.diff-lines {
background: var(--color-bg);
}
.diff-line {
display: flex;
line-height: 1.5;
white-space: pre;
}
.line-origin {
width: 20px;
text-align: center;
user-select: none;
color: var(--color-text-secondary);
}
.line-content {
flex: 1;
padding-right: 12px;
overflow-x: auto;
}
.line-add {
background: rgba(46, 160, 67, 0.15);
}
.line-add .line-origin {
color: var(--color-success);
}
.line-del {
background: rgba(248, 81, 73, 0.15);
}
.line-del .line-origin {
color: var(--color-danger);
}
/* No Changes */
.no-changes {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 40px 20px;
color: var(--color-text-secondary);
gap: 8px;
}
.no-changes .icon {
font-size: 32px;
color: var(--color-success);
}
/* History Tab */
.history-tab {
padding: 8px;
}
.commit-list {
display: flex;
flex-direction: column;
gap: 2px;
}
.commit-item {
padding: 12px;
border-radius: 6px;
cursor: pointer;
border: 1px solid transparent;
}
.commit-item:hover {
background: var(--color-bg-hover);
}
.commit-item.selected {
background: var(--color-bg-secondary);
border-color: var(--color-border);
}
.commit-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 4px;
}
.commit-id {
font-family: var(--font-mono);
font-size: 12px;
color: var(--color-primary);
font-weight: 500;
}
.commit-time {
font-size: 11px;
color: var(--color-text-secondary);
}
.commit-message {
font-size: 13px;
margin-bottom: 4px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.commit-meta {
display: flex;
gap: 12px;
font-size: 11px;
color: var(--color-text-secondary);
}
.commit-diff {
margin-top: 12px;
padding-top: 12px;
border-top: 1px solid var(--color-border);
}
/* Loading */
.loading {
text-align: center;
padding: 20px;
color: var(--color-text-secondary);
font-size: 13px;
}
.no-history {
text-align: center;
padding: 40px 20px;
color: var(--color-text-secondary);
}
</style>

View File

@@ -0,0 +1,123 @@
<script setup lang="ts">
import { useGitStore } from '../stores'
import GitPanel from './GitPanel.vue'
const gitStore = useGitStore()
function openPanel() {
gitStore.togglePanel()
}
</script>
<template>
<!-- Git Panel (slides in from right) -->
<GitPanel />
<div v-if="gitStore.isRepo" class="git-status" @click="openPanel" title="Open Git panel">
<div class="git-info">
<span :class="['git-indicator', { 'has-changes': gitStore.hasChanges }]"></span>
<span class="git-branch">{{ gitStore.branch }}</span>
<span v-if="gitStore.hasChanges" class="git-changes">
{{ gitStore.changedFilesCount }} changes
</span>
</div>
<div class="git-sync-status">
<span v-if="gitStore.remote?.ahead" class="sync-ahead" title="Commits ahead">
{{ gitStore.remote.ahead }}
</span>
<span v-if="gitStore.remote?.behind" class="sync-behind" title="Commits behind">
{{ gitStore.remote.behind }}
</span>
<span class="expand-hint"></span>
</div>
</div>
<!-- Conflict Warning -->
<div v-if="gitStore.hasConflicts" class="git-conflicts" @click="openPanel">
Git conflicts detected ({{ gitStore.conflicts.length }} files)
</div>
</template>
<style scoped>
.git-status {
padding: 10px 16px;
border-top: 1px solid var(--color-border);
font-size: 12px;
display: flex;
align-items: center;
justify-content: space-between;
cursor: pointer;
transition: background 0.15s;
}
.git-status:hover {
background: var(--color-bg-hover);
}
.git-info {
display: flex;
align-items: center;
gap: 6px;
}
.git-indicator {
color: var(--color-success);
}
.git-indicator.has-changes {
color: var(--color-primary);
animation: pulse 2s infinite;
}
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.5; }
}
.git-branch {
font-weight: 500;
}
.git-changes {
color: var(--color-text-secondary);
background: var(--color-bg);
padding: 2px 6px;
border-radius: 4px;
font-size: 11px;
}
.git-sync-status {
display: flex;
align-items: center;
gap: 6px;
}
.sync-ahead {
color: var(--color-success);
font-size: 11px;
font-weight: 500;
}
.sync-behind {
color: var(--color-warning);
font-size: 11px;
font-weight: 500;
}
.expand-hint {
color: var(--color-text-secondary);
font-size: 14px;
}
.git-conflicts {
padding: 8px 16px;
background: var(--color-danger);
color: white;
font-size: 12px;
cursor: pointer;
}
.git-conflicts:hover {
background: #c82333;
}
</style>

View File

@@ -0,0 +1,533 @@
<script setup lang="ts">
import { ref, onMounted, onUnmounted, watch, shallowRef } from 'vue'
import { EditorState } from '@codemirror/state'
import { EditorView, keymap, lineNumbers, highlightActiveLine, highlightActiveLineGutter } from '@codemirror/view'
import { defaultKeymap, history, historyKeymap } from '@codemirror/commands'
import { markdown, markdownLanguage } from '@codemirror/lang-markdown'
import { syntaxHighlighting, defaultHighlightStyle, bracketMatching } from '@codemirror/language'
import { oneDark } from '@codemirror/theme-one-dark'
import { useThemeStore } from '../stores'
import { assetsApi } from '../api/client'
import EditorToolbar from './EditorToolbar.vue'
const props = defineProps<{
modelValue: string
readonly?: boolean
placeholder?: string
projectId?: string
}>()
const emit = defineEmits<{
'update:modelValue': [value: string]
}>()
const themeStore = useThemeStore()
const editorContainer = ref<HTMLDivElement | null>(null)
const editorView = shallowRef<EditorView | null>(null)
const uploading = ref(false)
// Check if dark mode is active
function isDarkMode(): boolean {
return themeStore.getEffectiveTheme() === 'dark'
}
// Create custom theme for light mode
const lightTheme = EditorView.theme({
'&': {
backgroundColor: 'var(--color-bg)',
color: 'var(--color-text)'
},
'.cm-content': {
fontFamily: "'SF Mono', 'Monaco', 'Inconsolata', 'Fira Code', monospace",
fontSize: '14px',
lineHeight: '1.6',
padding: '16px 0'
},
'.cm-gutters': {
backgroundColor: 'var(--color-bg-secondary)',
color: 'var(--color-text-secondary)',
border: 'none',
borderRight: '1px solid var(--color-border)'
},
'.cm-activeLineGutter': {
backgroundColor: 'var(--color-border)'
},
'.cm-activeLine': {
backgroundColor: 'rgba(0, 0, 0, 0.03)'
},
'&.cm-focused .cm-cursor': {
borderLeftColor: 'var(--color-primary)'
},
'&.cm-focused .cm-selectionBackground, ::selection': {
backgroundColor: 'rgba(3, 102, 214, 0.2)'
},
'.cm-scroller': {
overflow: 'auto'
}
})
// Create dark theme extension
const darkTheme = EditorView.theme({
'&': {
backgroundColor: 'var(--color-bg)',
color: 'var(--color-text)'
},
'.cm-content': {
fontFamily: "'SF Mono', 'Monaco', 'Inconsolata', 'Fira Code', monospace",
fontSize: '14px',
lineHeight: '1.6',
padding: '16px 0'
},
'.cm-gutters': {
backgroundColor: 'var(--color-bg-secondary)',
color: 'var(--color-text-secondary)',
border: 'none',
borderRight: '1px solid var(--color-border)'
},
'.cm-activeLineGutter': {
backgroundColor: 'var(--color-border)'
},
'.cm-activeLine': {
backgroundColor: 'rgba(255, 255, 255, 0.03)'
},
'&.cm-focused .cm-cursor': {
borderLeftColor: 'var(--color-primary)'
},
'&.cm-focused .cm-selectionBackground, ::selection': {
backgroundColor: 'rgba(79, 195, 247, 0.2)'
},
'.cm-scroller': {
overflow: 'auto'
}
}, { dark: true })
// Markdown formatting keybindings
function toggleBold(view: EditorView): boolean {
const { from, to } = view.state.selection.main
const selectedText = view.state.sliceDoc(from, to)
if (selectedText) {
// Check if already bold
const isBold = selectedText.startsWith('**') && selectedText.endsWith('**')
let newText: string
if (isBold) {
newText = selectedText.slice(2, -2)
} else {
newText = `**${selectedText}**`
}
view.dispatch({
changes: { from, to, insert: newText }
})
}
return true
}
function toggleItalic(view: EditorView): boolean {
const { from, to } = view.state.selection.main
const selectedText = view.state.sliceDoc(from, to)
if (selectedText) {
// Check if already italic (single asterisk, not bold)
const isItalic = selectedText.startsWith('*') && selectedText.endsWith('*') &&
!selectedText.startsWith('**')
let newText: string
if (isItalic) {
newText = selectedText.slice(1, -1)
} else {
newText = `*${selectedText}*`
}
view.dispatch({
changes: { from, to, insert: newText }
})
}
return true
}
function toggleCode(view: EditorView): boolean {
const { from, to } = view.state.selection.main
const selectedText = view.state.sliceDoc(from, to)
if (selectedText) {
const isCode = selectedText.startsWith('`') && selectedText.endsWith('`')
let newText: string
if (isCode) {
newText = selectedText.slice(1, -1)
} else {
newText = `\`${selectedText}\``
}
view.dispatch({
changes: { from, to, insert: newText }
})
}
return true
}
const markdownKeymap = keymap.of([
{ key: 'Mod-b', run: toggleBold },
{ key: 'Mod-i', run: toggleItalic },
{ key: 'Mod-`', run: toggleCode }
])
// Toolbar format handler
function handleFormat(type: string, extra?: string) {
const view = editorView.value
if (!view) return
const { from, to } = view.state.selection.main
const selectedText = view.state.sliceDoc(from, to)
const line = view.state.doc.lineAt(from)
const lineStart = line.from
const lineText = line.text
let insert = ''
let newFrom = from
let newTo = to
switch (type) {
case 'bold':
if (selectedText) {
insert = `**${selectedText}**`
} else {
insert = '**bold**'
newFrom = from + 2
newTo = from + 6
}
break
case 'italic':
if (selectedText) {
insert = `*${selectedText}*`
} else {
insert = '*italic*'
newFrom = from + 1
newTo = from + 7
}
break
case 'strikethrough':
if (selectedText) {
insert = `~~${selectedText}~~`
} else {
insert = '~~strikethrough~~'
newFrom = from + 2
newTo = from + 15
}
break
case 'code':
if (selectedText) {
insert = `\`${selectedText}\``
} else {
insert = '`code`'
newFrom = from + 1
newTo = from + 5
}
break
case 'codeblock':
if (selectedText) {
insert = `\n\`\`\`\n${selectedText}\n\`\`\`\n`
} else {
insert = '\n```\ncode\n```\n'
newFrom = from + 5
newTo = from + 9
}
break
case 'heading':
const level = parseInt(extra || '2')
const prefix = '#'.repeat(level) + ' '
// Check if line already has heading
const headingMatch = lineText.match(/^(#{1,6})\s/)
if (headingMatch) {
// Replace existing heading
view.dispatch({
changes: { from: lineStart, to: lineStart + headingMatch[0].length, insert: prefix }
})
return
} else {
// Insert at line start
view.dispatch({
changes: { from: lineStart, to: lineStart, insert: prefix }
})
return
}
case 'quote':
// Add > at start of each selected line
if (selectedText.includes('\n')) {
insert = selectedText.split('\n').map(l => `> ${l}`).join('\n')
} else if (selectedText) {
insert = `> ${selectedText}`
} else {
view.dispatch({
changes: { from: lineStart, to: lineStart, insert: '> ' }
})
return
}
break
case 'bullet':
view.dispatch({
changes: { from: lineStart, to: lineStart, insert: '- ' }
})
return
case 'numbered':
view.dispatch({
changes: { from: lineStart, to: lineStart, insert: '1. ' }
})
return
case 'task':
view.dispatch({
changes: { from: lineStart, to: lineStart, insert: '- [ ] ' }
})
return
case 'hr':
insert = '\n---\n'
break
default:
return
}
view.dispatch({
changes: { from, to, insert },
selection: { anchor: newFrom, head: newTo }
})
view.focus()
}
// Link insertion with prompt
function handleInsertLink() {
const view = editorView.value
if (!view) return
const { from, to } = view.state.selection.main
const selectedText = view.state.sliceDoc(from, to)
const url = prompt('Enter URL:', 'https://')
if (!url) return
const linkText = selectedText || 'link text'
const insert = `[${linkText}](${url})`
view.dispatch({
changes: { from, to, insert }
})
view.focus()
}
// Image upload and insertion
async function handleInsertImage(file: File) {
const view = editorView.value
if (!view) return
uploading.value = true
try {
// Upload via assets API
const result = await assetsApi.upload(file, props.projectId)
// Insert markdown image
const { from, to } = view.state.selection.main
const altText = file.name.replace(/\.[^/.]+$/, '') // filename without extension
const insert = `![${altText}](${result.url})`
view.dispatch({
changes: { from, to, insert }
})
view.focus()
} catch (err) {
console.error('Failed to upload image:', err)
alert('Failed to upload image: ' + (err instanceof Error ? err.message : 'Unknown error'))
} finally {
uploading.value = false
}
}
function createEditorState(content: string): EditorState {
const dark = isDarkMode()
const extensions = [
lineNumbers(),
highlightActiveLine(),
highlightActiveLineGutter(),
history(),
bracketMatching(),
markdown({ base: markdownLanguage }),
syntaxHighlighting(defaultHighlightStyle, { fallback: true }),
dark ? darkTheme : lightTheme,
dark ? oneDark : [],
keymap.of([
...defaultKeymap,
...historyKeymap
]),
markdownKeymap,
EditorView.updateListener.of((update) => {
if (update.docChanged) {
emit('update:modelValue', update.state.doc.toString())
}
}),
EditorState.readOnly.of(props.readonly ?? false),
EditorView.editable.of(!(props.readonly ?? false))
].flat()
return EditorState.create({
doc: content,
extensions
})
}
function initEditor() {
if (!editorContainer.value) return
const state = createEditorState(props.modelValue)
editorView.value = new EditorView({
state,
parent: editorContainer.value
})
}
function destroyEditor() {
if (editorView.value) {
editorView.value.destroy()
editorView.value = null
}
}
// Watch for external content changes
watch(() => props.modelValue, (newValue) => {
if (!editorView.value) return
const currentValue = editorView.value.state.doc.toString()
if (newValue !== currentValue) {
editorView.value.dispatch({
changes: {
from: 0,
to: editorView.value.state.doc.length,
insert: newValue
}
})
}
})
// Watch for readonly changes
watch(() => props.readonly, () => {
if (!editorView.value) return
// Recreate editor with new readonly state
const content = editorView.value.state.doc.toString()
destroyEditor()
initEditor()
// Restore content
if (editorView.value && content) {
editorView.value.dispatch({
changes: {
from: 0,
to: editorView.value.state.doc.length,
insert: content
}
})
}
})
// Listen for theme changes
function handleThemeChange() {
if (!editorView.value) return
destroyEditor()
initEditor()
// Content will be restored via props.modelValue
}
// Watch theme store changes
watch(() => themeStore.getEffectiveTheme(), handleThemeChange)
onMounted(() => {
initEditor()
})
onUnmounted(() => {
destroyEditor()
})
</script>
<template>
<div class="markdown-editor-wrapper" :class="{ readonly, uploading }">
<!-- Toolbar (shows when not readonly) -->
<EditorToolbar
v-if="!readonly"
@format="handleFormat"
@insert-link="handleInsertLink"
@insert-image="handleInsertImage"
/>
<!-- Upload indicator -->
<div v-if="uploading" class="upload-indicator">
Uploading image...
</div>
<!-- Editor -->
<div ref="editorContainer" class="markdown-editor"></div>
</div>
</template>
<style scoped>
.markdown-editor-wrapper {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
position: relative;
}
.markdown-editor {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
}
.markdown-editor :deep(.cm-editor) {
flex: 1;
overflow: hidden;
}
.markdown-editor :deep(.cm-scroller) {
padding: 0 24px;
}
.markdown-editor-wrapper.readonly .markdown-editor :deep(.cm-editor) {
opacity: 0.7;
}
.upload-indicator {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
background: var(--color-bg-secondary);
border: 1px solid var(--color-border);
padding: 12px 24px;
border-radius: 8px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
z-index: 10;
font-size: 13px;
color: var(--color-text);
}
.markdown-editor-wrapper.uploading .markdown-editor {
opacity: 0.5;
pointer-events: none;
}
</style>

View File

@@ -0,0 +1,204 @@
<script setup lang="ts">
import { computed, ref, onMounted } from 'vue'
import MarkdownIt from 'markdown-it'
const props = defineProps<{
content: string
}>()
// Initialize markdown-it with CommonMark preset
const md = ref<MarkdownIt | null>(null)
onMounted(() => {
md.value = new MarkdownIt({
html: false, // Disable HTML tags in source
xhtmlOut: true, // Use '/' to close single tags (<br />)
breaks: true, // Convert '\n' in paragraphs into <br>
linkify: true, // Autoconvert URL-like text to links
typographer: true, // Enable smartquotes and other typographic replacements
})
})
const renderedHtml = computed(() => {
if (!md.value) return ''
try {
return md.value.render(props.content)
} catch (e) {
console.error('Markdown rendering error:', e)
return `<pre>${props.content}</pre>`
}
})
</script>
<template>
<div class="markdown-preview">
<div class="preview-content" v-html="renderedHtml"></div>
</div>
</template>
<style scoped>
.markdown-preview {
flex: 1;
overflow-y: auto;
padding: 16px 24px;
background: var(--color-bg);
}
.preview-content {
max-width: 800px;
line-height: 1.7;
}
/* Markdown styling */
.preview-content :deep(h1) {
font-size: 2em;
font-weight: 600;
margin: 0.67em 0;
padding-bottom: 0.3em;
border-bottom: 1px solid var(--color-border);
}
.preview-content :deep(h2) {
font-size: 1.5em;
font-weight: 600;
margin: 1em 0 0.5em;
padding-bottom: 0.3em;
border-bottom: 1px solid var(--color-border);
}
.preview-content :deep(h3) {
font-size: 1.25em;
font-weight: 600;
margin: 1em 0 0.5em;
}
.preview-content :deep(h4),
.preview-content :deep(h5),
.preview-content :deep(h6) {
font-size: 1em;
font-weight: 600;
margin: 1em 0 0.5em;
}
.preview-content :deep(p) {
margin: 0.5em 0 1em;
}
.preview-content :deep(ul),
.preview-content :deep(ol) {
margin: 0.5em 0 1em;
padding-left: 2em;
}
.preview-content :deep(li) {
margin: 0.25em 0;
}
.preview-content :deep(li > ul),
.preview-content :deep(li > ol) {
margin: 0.25em 0;
}
.preview-content :deep(blockquote) {
margin: 1em 0;
padding: 0.5em 1em;
border-left: 4px solid var(--color-primary);
background: var(--color-bg-secondary);
color: var(--color-text-secondary);
}
.preview-content :deep(blockquote p) {
margin: 0;
}
.preview-content :deep(code) {
font-family: 'SF Mono', 'Monaco', 'Inconsolata', 'Fira Code', monospace;
font-size: 0.9em;
padding: 0.2em 0.4em;
background: var(--color-bg-secondary);
border-radius: 4px;
}
.preview-content :deep(pre) {
margin: 1em 0;
padding: 1em;
background: var(--color-bg-secondary);
border-radius: 6px;
overflow-x: auto;
}
.preview-content :deep(pre code) {
padding: 0;
background: transparent;
font-size: 0.85em;
line-height: 1.5;
}
.preview-content :deep(hr) {
margin: 2em 0;
border: none;
border-top: 1px solid var(--color-border);
}
.preview-content :deep(a) {
color: var(--color-primary);
text-decoration: none;
}
.preview-content :deep(a:hover) {
text-decoration: underline;
}
.preview-content :deep(img) {
max-width: 100%;
height: auto;
border-radius: 6px;
}
.preview-content :deep(table) {
width: 100%;
margin: 1em 0;
border-collapse: collapse;
}
.preview-content :deep(th),
.preview-content :deep(td) {
padding: 0.5em 1em;
border: 1px solid var(--color-border);
text-align: left;
}
.preview-content :deep(th) {
background: var(--color-bg-secondary);
font-weight: 600;
}
.preview-content :deep(tr:nth-child(even)) {
background: var(--color-bg-secondary);
}
/* Task list styling */
.preview-content :deep(input[type="checkbox"]) {
margin-right: 0.5em;
}
.preview-content :deep(li.task-list-item) {
list-style: none;
margin-left: -1.5em;
}
/* Strong and emphasis */
.preview-content :deep(strong) {
font-weight: 600;
}
.preview-content :deep(em) {
font-style: italic;
}
.preview-content :deep(del) {
text-decoration: line-through;
color: var(--color-text-secondary);
}
</style>

View File

@@ -0,0 +1,108 @@
<script setup lang="ts">
import { ref, computed } from 'vue'
import { MilkdownProvider } from '@milkdown/vue'
import { Crepe } from '@milkdown/crepe'
import { useThemeStore } from '../stores'
import { assetsApi } from '../api/client'
import MilkdownEditorCore from './MilkdownEditorCore.vue'
const props = defineProps<{
modelValue: string
readonly?: boolean
placeholder?: string
projectId?: string
editorKey?: string | number
}>()
const emit = defineEmits<{
'update:modelValue': [value: string]
}>()
const themeStore = useThemeStore()
const uploading = ref(false)
const editorInstance = ref<Crepe | null>(null)
const isDarkMode = computed(() => themeStore.getEffectiveTheme() === 'dark')
// Handle content updates from the core editor
function handleContentUpdate(value: string) {
emit('update:modelValue', value)
}
// Store editor instance when ready
function handleEditorReady(crepe: Crepe) {
editorInstance.value = crepe
}
</script>
<template>
<div
class="milkdown-editor-wrapper"
:class="{
readonly,
uploading,
'dark-mode': isDarkMode
}"
>
<!-- Upload indicator -->
<div v-if="uploading" class="upload-indicator">
Uploading image...
</div>
<!-- Milkdown Editor with Crepe (includes built-in toolbar) -->
<!-- CRITICAL: Key the entire container to force full remount when switching notes/tasks -->
<!-- This ensures the Milkdown editor instance is completely recreated, not just updated -->
<div :key="editorKey" class="milkdown-container" :class="{ 'is-readonly': readonly }">
<MilkdownProvider>
<MilkdownEditorCore
:model-value="modelValue"
:readonly="readonly"
@update:model-value="handleContentUpdate"
@editor-ready="handleEditorReady"
/>
</MilkdownProvider>
</div>
</div>
</template>
<style scoped>
.milkdown-editor-wrapper {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
position: relative;
}
.milkdown-container {
flex: 1;
display: flex;
flex-direction: column;
overflow: auto;
}
.milkdown-container.is-readonly {
pointer-events: none;
opacity: 0.7;
}
.upload-indicator {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
background: var(--color-bg-secondary);
border: 1px solid var(--color-border);
padding: 12px 24px;
border-radius: 8px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
z-index: 10;
font-size: 13px;
color: var(--color-text);
}
.milkdown-editor-wrapper.uploading .milkdown-container {
opacity: 0.5;
pointer-events: none;
}
</style>

View File

@@ -0,0 +1,442 @@
<script setup lang="ts">
import { watch, computed, ref, onUnmounted } from 'vue'
import { Milkdown, useEditor } from '@milkdown/vue'
import { Crepe, CrepeFeature } from '@milkdown/crepe'
import { listener, listenerCtx } from '@milkdown/kit/plugin/listener'
import { replaceAll } from '@milkdown/kit/utils'
import { useThemeStore } from '../stores'
// Import Crepe common styles (layout, components)
import '@milkdown/crepe/theme/common/style.css'
// Import the frame theme (light) - we override for dark mode via CSS
import '@milkdown/crepe/theme/frame.css'
const props = defineProps<{
modelValue: string
readonly?: boolean
}>()
const emit = defineEmits<{
'update:modelValue': [value: string]
'editor-ready': [editor: Crepe]
}>()
const themeStore = useThemeStore()
// Use mode directly for reactivity, then compute effective theme
const isDarkMode = computed(() => {
const mode = themeStore.mode
if (mode === 'system') {
return window.matchMedia('(prefers-color-scheme: dark)').matches
}
return mode === 'dark'
})
// CRITICAL: Use refs for instance-scoped state that must reset when component recreates
// These were previously module-level lets which caused stale content bugs when switching notes/tasks
const isExternalUpdate = ref(false)
const currentContent = ref(props.modelValue)
const pendingContent = ref<string | null>(null)
const editorReady = ref(false)
// Cleanup any pending timeouts/intervals on unmount
let externalUpdateTimeout: ReturnType<typeof setTimeout> | null = null
let pendingRetryInterval: ReturnType<typeof setInterval> | null = null
onUnmounted(() => {
if (externalUpdateTimeout) {
clearTimeout(externalUpdateTimeout)
externalUpdateTimeout = null
}
if (pendingRetryInterval) {
clearInterval(pendingRetryInterval)
pendingRetryInterval = null
}
})
// Try to apply pending content - called when editor might be ready
function tryApplyPendingContent() {
if (pendingContent.value === null) return false
const crepe = get()
if (!crepe) return false
try {
const editor = crepe.editor
if (!editor || typeof editor.action !== 'function') return false
console.log('[MilkdownEditorCore] Applying pending content, length:', pendingContent.value.length)
isExternalUpdate.value = true
editor.action(replaceAll(pendingContent.value))
currentContent.value = pendingContent.value
pendingContent.value = null
editorReady.value = true
if (externalUpdateTimeout) clearTimeout(externalUpdateTimeout)
externalUpdateTimeout = setTimeout(() => { isExternalUpdate.value = false }, 50)
// Stop retry interval if running
if (pendingRetryInterval) {
clearInterval(pendingRetryInterval)
pendingRetryInterval = null
}
return true
} catch (err) {
console.warn('[MilkdownEditorCore] Failed to apply pending content:', err)
return false
}
}
const { get, loading } = useEditor((root) => {
const crepe = new Crepe({
root,
defaultValue: props.modelValue,
features: {
[CrepeFeature.CodeMirror]: true,
[CrepeFeature.ListItem]: true,
[CrepeFeature.LinkTooltip]: true,
[CrepeFeature.Cursor]: true,
[CrepeFeature.ImageBlock]: true,
[CrepeFeature.BlockEdit]: true,
[CrepeFeature.Toolbar]: true,
[CrepeFeature.Placeholder]: true,
[CrepeFeature.Table]: true,
[CrepeFeature.Latex]: false, // Disable LaTeX for now
},
featureConfigs: {
[CrepeFeature.Placeholder]: {
text: 'Start writing...',
},
},
})
// Add listener plugin for content changes
crepe.editor
.config((ctx) => {
const listenerHandler = ctx.get(listenerCtx)
listenerHandler.markdownUpdated((ctx, markdown, prevMarkdown) => {
// CRITICAL: Only emit content changes if:
// 1. Content actually changed
// 2. We're not in the middle of an external update
// 3. Editor is ready (not still applying pending content)
// 4. No pending content waiting to be applied (prevents emitting stale content)
if (markdown !== prevMarkdown && !isExternalUpdate.value && editorReady.value && pendingContent.value === null) {
console.log('[MilkdownEditorCore] User edit, emitting content length:', markdown.length)
currentContent.value = markdown
emit('update:modelValue', markdown)
} else if (markdown !== prevMarkdown) {
console.log('[MilkdownEditorCore] Content changed but not emitting:', {
isExternalUpdate: isExternalUpdate.value,
editorReady: editorReady.value,
hasPendingContent: pendingContent.value !== null
})
}
})
})
.use(listener)
return crepe
})
// Emit editor instance when ready, and apply any pending content
watch(loading, (isLoading) => {
if (!isLoading) {
const crepe = get()
if (crepe) {
emit('editor-ready', crepe)
// Try to apply pending content - might need retries if editor not fully ready
if (pendingContent.value !== null) {
if (!tryApplyPendingContent()) {
// Editor not ready yet, start retry interval
console.log('[MilkdownEditorCore] Editor not ready after loading, starting retry')
startPendingRetry()
}
} else {
editorReady.value = true
}
}
}
}, { immediate: true })
// Start a retry interval for applying pending content
function startPendingRetry() {
if (pendingRetryInterval) return // Already retrying
let retryCount = 0
const maxRetries = 20 // 2 seconds max
pendingRetryInterval = setInterval(() => {
retryCount++
console.log('[MilkdownEditorCore] Retry attempt', retryCount, 'to apply pending content')
if (tryApplyPendingContent()) {
// Success - interval cleared in tryApplyPendingContent
return
}
if (retryCount >= maxRetries) {
console.error('[MilkdownEditorCore] Failed to apply pending content after', maxRetries, 'retries')
if (pendingRetryInterval) {
clearInterval(pendingRetryInterval)
pendingRetryInterval = null
}
}
}, 100)
}
// Watch for external content changes
watch(() => props.modelValue, async (newValue) => {
console.log('[MilkdownEditorCore] modelValue changed, length:', newValue?.length, 'loading:', loading.value, 'currentContent length:', currentContent.value?.length, 'editorReady:', editorReady.value)
// If editor is still loading, store the content to apply after load
if (loading.value) {
console.log('[MilkdownEditorCore] Editor loading, storing as pending content')
pendingContent.value = newValue
return
}
// Skip if content hasn't actually changed
if (newValue === currentContent.value) {
console.log('[MilkdownEditorCore] Content unchanged, skipping')
return
}
// Store new content as pending and try to apply
pendingContent.value = newValue
if (!tryApplyPendingContent()) {
// Editor not ready, start retry mechanism
console.log('[MilkdownEditorCore] Editor not ready, starting retry for new content')
startPendingRetry()
}
})
</script>
<template>
<div :class="['crepe-editor', { 'dark-theme': isDarkMode }]">
<Milkdown />
</div>
</template>
<style>
/*
* Dark theme override for Milkdown Crepe
* When .dark-theme is applied, override Crepe's light theme variables
*/
.crepe-editor.dark-theme .milkdown {
--crepe-color-background: #1a1a1a;
--crepe-color-on-background: #e0e0e0;
--crepe-color-surface: #232323;
--crepe-color-surface-low: #1a1a1a;
--crepe-color-on-surface: #e0e0e0;
--crepe-color-on-surface-variant: #999999;
--crepe-color-outline: #3c3c3c;
--crepe-color-primary: #58a6ff;
--crepe-color-secondary: #232323;
--crepe-color-on-secondary: #e0e0e0;
--crepe-color-inverse: #e0e0e0;
--crepe-color-on-inverse: #1a1a1a;
--crepe-color-inline-code: #58a6ff;
--crepe-color-error: #f85149;
--crepe-color-hover: #2d2d2d;
--crepe-color-selected: #3c3c3c;
--crepe-color-inline-area: #2d2d2d;
--crepe-shadow-1: 0px 1px 2px 0px rgba(0, 0, 0, 0.5), 0px 1px 3px 1px rgba(0, 0, 0, 0.3);
--crepe-shadow-2: 0px 1px 2px 0px rgba(0, 0, 0, 0.5), 0px 2px 6px 2px rgba(0, 0, 0, 0.3);
}
/* Editor container layout */
.crepe-editor {
height: 100%;
display: flex;
flex-direction: column;
}
/* Dark theme background for container */
.crepe-editor.dark-theme {
background: #1a1a1a;
color: #e0e0e0;
}
.crepe-editor .milkdown {
height: 100%;
flex: 1;
display: flex;
flex-direction: column;
}
/* The actual editor area */
.crepe-editor .ProseMirror {
flex: 1;
min-height: 200px;
outline: none;
padding: 16px;
}
/* Dark theme for ProseMirror content area */
.crepe-editor.dark-theme .ProseMirror {
background: #1a1a1a;
color: #e0e0e0;
}
/* Toolbar styling - dark theme */
.crepe-editor.dark-theme .milkdown-toolbar,
.crepe-editor.dark-theme milkdown-toolbar {
background: #232323;
border-bottom: 1px solid #3c3c3c;
}
.crepe-editor.dark-theme .milkdown-toolbar button,
.crepe-editor.dark-theme milkdown-toolbar button {
color: #e0e0e0;
}
.crepe-editor.dark-theme .milkdown-toolbar button:hover,
.crepe-editor.dark-theme milkdown-toolbar button:hover {
background: #2d2d2d;
}
/* Block handle and menus - dark theme */
.crepe-editor.dark-theme [data-block-handle],
.crepe-editor.dark-theme .slash-menu,
.crepe-editor.dark-theme .link-tooltip,
.crepe-editor.dark-theme milkdown-slash-menu,
.crepe-editor.dark-theme milkdown-link-tooltip {
background: #232323;
border: 1px solid #3c3c3c;
color: #e0e0e0;
}
/* Menu items - dark theme */
.crepe-editor.dark-theme .slash-menu-item,
.crepe-editor.dark-theme [role="menuitem"] {
color: #e0e0e0;
}
.crepe-editor.dark-theme .slash-menu-item:hover,
.crepe-editor.dark-theme [role="menuitem"]:hover {
background: #2d2d2d;
}
/* Code blocks - dark theme */
.crepe-editor.dark-theme pre {
background: #232323;
border: 1px solid #3c3c3c;
border-radius: 6px;
padding: 12px;
}
.crepe-editor.dark-theme pre code {
background: transparent;
color: #e0e0e0;
}
.crepe-editor code {
font-family: var(--font-mono, 'SF Mono', 'Monaco', 'Menlo', 'Consolas', monospace);
font-size: 13px;
}
/* Inline code - dark theme */
.crepe-editor.dark-theme :not(pre) > code {
background: #2d2d2d;
padding: 2px 6px;
border-radius: 4px;
color: #58a6ff;
}
/* Blockquote - dark theme */
.crepe-editor.dark-theme blockquote {
border-left: 3px solid #3c3c3c;
padding-left: 16px;
margin-left: 0;
color: #999999;
}
/* Tables - dark theme */
.crepe-editor table {
border-collapse: collapse;
width: 100%;
}
.crepe-editor.dark-theme th,
.crepe-editor.dark-theme td {
border: 1px solid #3c3c3c;
padding: 8px 12px;
color: #e0e0e0;
}
.crepe-editor.dark-theme th {
background: #232323;
font-weight: 600;
}
/* Links - dark theme */
.crepe-editor.dark-theme a {
color: #58a6ff;
text-decoration: none;
}
.crepe-editor.dark-theme a:hover {
text-decoration: underline;
}
/* Horizontal rule - dark theme */
.crepe-editor.dark-theme hr {
border: none;
border-top: 1px solid #3c3c3c;
margin: 24px 0;
}
/* Task list */
.crepe-editor li[data-task-list-item] {
list-style: none;
}
.crepe-editor li[data-task-list-item]::before {
content: none;
}
/* Headings - dark theme */
.crepe-editor.dark-theme h1,
.crepe-editor.dark-theme h2,
.crepe-editor.dark-theme h3,
.crepe-editor.dark-theme h4,
.crepe-editor.dark-theme h5,
.crepe-editor.dark-theme h6 {
color: #e0e0e0;
}
/* Lists - dark theme */
.crepe-editor.dark-theme ul,
.crepe-editor.dark-theme ol,
.crepe-editor.dark-theme li {
color: #e0e0e0;
}
/* Placeholder - dark theme */
.crepe-editor.dark-theme .ProseMirror p.is-editor-empty:first-child::before {
color: #999999;
content: attr(data-placeholder);
float: left;
height: 0;
pointer-events: none;
}
/* Selection - dark theme */
.crepe-editor.dark-theme .ProseMirror ::selection {
background: #58a6ff;
color: white;
}
/* Focus state */
.crepe-editor .ProseMirror:focus {
outline: none;
}
/* Image blocks */
.crepe-editor img {
max-width: 100%;
border-radius: 6px;
}
</style>

View File

@@ -0,0 +1,102 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { useNotesStore } from '../stores'
const router = useRouter()
const route = useRoute()
const notesStore = useNotesStore()
const selectedNoteId = computed(() => route.params.id as string | undefined)
function selectNote(id: string) {
router.push({ name: 'note', params: { id } })
}
function formatDate(dateStr?: string): string {
if (!dateStr) return ''
try {
const date = new Date(dateStr)
return date.toLocaleDateString(undefined, {
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit'
})
} catch {
return dateStr
}
}
</script>
<template>
<div v-if="notesStore.loading" class="loading">Loading notes...</div>
<div v-else-if="notesStore.sortedNotes.length === 0" class="empty">No notes yet</div>
<ul v-else class="note-list">
<li
v-for="note in notesStore.sortedNotes"
:key="note.id"
:class="['note-item', { active: note.id === selectedNoteId }]"
@click="selectNote(note.id)"
>
<div class="note-item-title">{{ note.title }}</div>
<div class="note-item-meta">
<span class="type-badge">{{ note.note_type }}</span>
<span v-if="note.updated"> · {{ formatDate(note.updated) }}</span>
</div>
</li>
</ul>
</template>
<style scoped>
.note-list {
list-style: none;
margin: 0;
padding: 0;
}
.note-item {
padding: 10px 16px;
cursor: pointer;
border-left: 3px solid transparent;
transition: background 0.15s, border-color 0.15s;
}
.note-item:hover {
background: var(--color-border);
}
.note-item.active {
background: var(--color-border);
border-left-color: var(--color-primary);
}
.note-item-title {
font-weight: 500;
margin-bottom: 2px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.note-item-meta {
font-size: 12px;
color: var(--color-text-secondary);
}
.type-badge {
display: inline-block;
padding: 2px 6px;
border-radius: 4px;
font-size: 11px;
background: var(--color-border);
color: var(--color-text-secondary);
}
.loading,
.empty {
padding: 16px;
color: var(--color-text-secondary);
text-align: center;
}
</style>

View File

@@ -0,0 +1,124 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { useProjectsStore } from '../stores'
const emit = defineEmits<{
create: []
}>()
const router = useRouter()
const route = useRoute()
const projectsStore = useProjectsStore()
const selectedProjectId = computed(() => route.params.id as string | undefined)
function selectProject(id: string) {
router.push({ name: 'project', params: { id } })
}
function goToTasks(id: string, event: Event) {
event.stopPropagation()
router.push({ name: 'project-tasks', params: { id } })
}
</script>
<template>
<div class="project-list-container">
<div class="project-actions">
<button class="create-btn" @click="emit('create')">+ New Project</button>
</div>
<div v-if="projectsStore.loading" class="loading">Loading projects...</div>
<div v-else-if="projectsStore.sortedProjects.length === 0" class="empty">No projects yet</div>
<ul v-else class="project-list">
<li
v-for="project in projectsStore.sortedProjects"
:key="project.id"
:class="['project-item', { active: project.id === selectedProjectId }]"
@click="selectProject(project.id)"
>
<div class="project-item-content">
<div class="project-item-name">{{ project.name }}</div>
<button
class="tasks-btn"
@click="goToTasks(project.id, $event)"
title="View Tasks"
>
</button>
</div>
</li>
</ul>
</div>
</template>
<style scoped>
.project-list-container {
display: flex;
flex-direction: column;
}
.project-actions {
padding: 8px 16px;
}
.create-btn {
width: 100%;
padding: 8px;
font-size: 13px;
}
.project-list {
list-style: none;
margin: 0;
padding: 0;
}
.project-item {
padding: 10px 16px;
cursor: pointer;
border-left: 3px solid transparent;
transition: background 0.15s, border-color 0.15s;
}
.project-item:hover {
background: var(--color-border);
}
.project-item.active {
background: var(--color-border);
border-left-color: var(--color-primary);
}
.project-item-content {
display: flex;
justify-content: space-between;
align-items: center;
}
.project-item-name {
font-weight: 500;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.tasks-btn {
padding: 4px 8px;
font-size: 12px;
opacity: 0;
transition: opacity 0.15s;
}
.project-item:hover .tasks-btn {
opacity: 1;
}
.loading,
.empty {
padding: 16px;
color: var(--color-text-secondary);
text-align: center;
}
</style>

View File

@@ -0,0 +1,32 @@
<script setup lang="ts">
defineProps<{
message?: string
}>()
</script>
<template>
<div class="read-only-banner">
<span class="icon">🔒</span>
<span class="message">{{ message || 'This file is being edited elsewhere. Read-only mode.' }}</span>
</div>
</template>
<style scoped>
.read-only-banner {
padding: 8px 16px;
background: var(--color-primary);
color: white;
font-size: 13px;
display: flex;
align-items: center;
gap: 8px;
}
.icon {
font-size: 14px;
}
.message {
flex: 1;
}
</style>

View File

@@ -0,0 +1,127 @@
<script setup lang="ts">
import { ref, watch } from 'vue'
import { useRouter } from 'vue-router'
import { useNotesStore, useUiStore } from '../stores'
const router = useRouter()
const notesStore = useNotesStore()
const uiStore = useUiStore()
const localQuery = ref('')
let searchTimeout: number | null = null
watch(localQuery, (query) => {
if (searchTimeout) clearTimeout(searchTimeout)
searchTimeout = window.setTimeout(() => {
uiStore.search(query)
}, 300)
})
function openSearchResult(result: { path: string }) {
// Find note by path
const note = notesStore.notes.find(n => n.path === result.path)
if (note) {
router.push({ name: 'note', params: { id: note.id } })
uiStore.closeSearch()
}
}
</script>
<template>
<div class="search-panel">
<input
v-model="localQuery"
type="text"
placeholder="Search notes..."
class="search-input"
autofocus
/>
<div v-if="uiStore.isSearching" class="loading">Searching...</div>
<div v-else-if="uiStore.searchResults.length" class="search-results">
<div
v-for="result in uiStore.searchResults"
:key="result.path"
class="search-result"
@click="openSearchResult(result)"
>
<div class="search-result-title">{{ result.title }}</div>
<div
v-for="match in result.matches.slice(0, 2)"
:key="match.line_number"
class="search-result-match"
>
<span class="line-num">{{ match.line_number }}:</span>
{{ match.line_content.slice(0, 80) }}
</div>
</div>
</div>
<div v-else-if="localQuery.length >= 2" class="no-results">No results found</div>
</div>
</template>
<style scoped>
.search-panel {
padding: 12px;
border-bottom: 1px solid var(--color-border);
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
}
.search-input {
width: 100%;
padding: 8px 12px;
border: 1px solid var(--color-border);
border-radius: 6px;
background: var(--color-bg);
color: var(--color-text);
font-size: 14px;
outline: none;
}
.search-input:focus {
border-color: var(--color-primary);
}
.search-results {
margin-top: 12px;
flex: 1;
overflow-y: auto;
}
.search-result {
padding: 8px;
border-radius: 4px;
cursor: pointer;
}
.search-result:hover {
background: var(--color-border);
}
.search-result-title {
font-weight: 500;
margin-bottom: 4px;
}
.search-result-match {
font-size: 12px;
color: var(--color-text-secondary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.line-num {
color: var(--color-primary);
margin-right: 4px;
}
.no-results,
.loading {
padding: 16px;
text-align: center;
color: var(--color-text-secondary);
}
</style>

View File

@@ -0,0 +1,338 @@
<script setup lang="ts">
import { computed, watch } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { useWorkspaceStore, useTasksStore, useUiStore } from '../stores'
import SearchPanel from './SearchPanel.vue'
import TaskPanel from './TaskPanel.vue'
import GitStatus from './GitStatus.vue'
const router = useRouter()
const route = useRoute()
const workspaceStore = useWorkspaceStore()
const tasksStore = useTasksStore()
const uiStore = useUiStore()
const activeProject = computed(() => workspaceStore.activeProject)
const activeProjectId = computed(() => workspaceStore.activeProjectId)
// Load tasks when project changes
watch(activeProjectId, async (id) => {
if (id) {
await tasksStore.loadProjectTasks(id)
}
}, { immediate: true })
const activeTasks = computed(() => tasksStore.activeTasks)
const completedTasks = computed(() => tasksStore.completedTasks)
function goToProjectOverview() {
if (activeProjectId.value) {
router.push({ name: 'project', params: { id: activeProjectId.value } })
}
}
function goToProjectTasks() {
if (activeProjectId.value) {
router.push({ name: 'project-tasks', params: { id: activeProjectId.value } })
}
}
function goToProjectNotes() {
if (activeProjectId.value) {
router.push({ name: 'project-notes', params: { id: activeProjectId.value } })
}
}
function goToDaily() {
router.push({ name: 'daily' })
}
function goToCalendar() {
router.push({ name: 'calendar' })
}
function goToProjects() {
router.push({ name: 'projects' })
}
</script>
<template>
<div class="sidebar">
<!-- Search Panel (overlay) -->
<SearchPanel v-if="uiStore.showSearch" />
<!-- Tasks Panel (overlay) -->
<TaskPanel v-else-if="uiStore.showTasks" />
<!-- Main Content -->
<template v-else>
<!-- No Project Selected -->
<div v-if="!activeProject" class="no-project">
<div class="no-project-content">
<h3>No Project Selected</h3>
<p>Select a project from the dropdown above to get started.</p>
<button class="primary" @click="goToProjects">Browse Projects</button>
</div>
</div>
<!-- Project Content -->
<template v-else>
<!-- Project Navigation -->
<nav class="project-nav">
<button
:class="['nav-item', { active: route.name === 'project' }]"
@click="goToProjectOverview"
>
Overview
</button>
<button
:class="['nav-item', { active: route.name === 'project-notes' }]"
@click="goToProjectNotes"
>
Notes
</button>
<button
:class="['nav-item', { active: route.name === 'project-tasks' }]"
@click="goToProjectTasks"
>
Tasks
</button>
<button
:class="['nav-item', { active: route.name === 'daily' || route.name === 'daily-note' }]"
@click="goToDaily"
>
Daily
</button>
<button
:class="['nav-item', { active: route.name === 'calendar' }]"
@click="goToCalendar"
>
Calendar
</button>
</nav>
<!-- Quick Stats -->
<div class="quick-stats">
<div class="stat-item" @click="goToProjectTasks">
<span class="stat-value">{{ activeTasks.length }}</span>
<span class="stat-label">Active Tasks</span>
</div>
<div class="stat-item" @click="goToProjectTasks">
<span class="stat-value">{{ completedTasks.length }}</span>
<span class="stat-label">Completed</span>
</div>
</div>
<!-- Recent Tasks Preview -->
<div class="sidebar-section">
<div class="section-header">
<h4>Active Tasks</h4>
<button class="link-btn" @click="goToProjectTasks">View All</button>
</div>
<div v-if="activeTasks.length === 0" class="empty-section">
No active tasks
</div>
<ul v-else class="task-preview-list">
<li v-for="task in activeTasks.slice(0, 5)" :key="task.id" class="task-preview-item">
<span class="task-checkbox"></span>
<span class="task-text">{{ task.title }}</span>
</li>
<li v-if="activeTasks.length > 5" class="task-more">
+{{ activeTasks.length - 5 }} more...
</li>
</ul>
</div>
</template>
</template>
<!-- Git Status -->
<GitStatus />
</div>
</template>
<style scoped>
.sidebar {
width: var(--sidebar-width);
min-width: var(--sidebar-width);
max-width: var(--sidebar-width);
flex-shrink: 0;
background: var(--color-bg-secondary);
border-right: 1px solid var(--color-border);
display: flex;
flex-direction: column;
overflow: hidden;
}
.no-project {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
padding: 24px;
}
.no-project-content {
text-align: center;
}
.no-project-content h3 {
margin-bottom: 8px;
color: var(--color-text);
}
.no-project-content p {
margin-bottom: 16px;
color: var(--color-text-secondary);
font-size: 13px;
}
.project-nav {
display: flex;
flex-wrap: wrap;
gap: 4px;
padding: 12px;
border-bottom: 1px solid var(--color-border);
}
.nav-item {
flex: 1;
min-width: 70px;
padding: 8px 12px;
border: none;
border-radius: 6px;
background: transparent;
color: var(--color-text-secondary);
font-size: 12px;
font-weight: 500;
cursor: pointer;
transition: background 0.15s, color 0.15s;
}
.nav-item:hover {
background: var(--color-border);
color: var(--color-text);
}
.nav-item.active {
background: var(--color-primary);
color: white;
}
.quick-stats {
display: flex;
gap: 12px;
padding: 16px;
border-bottom: 1px solid var(--color-border);
}
.stat-item {
flex: 1;
text-align: center;
padding: 12px;
background: var(--color-bg);
border-radius: 8px;
cursor: pointer;
transition: background 0.15s;
}
.stat-item:hover {
background: var(--color-border);
}
.stat-value {
display: block;
font-size: 24px;
font-weight: 600;
color: var(--color-text);
}
.stat-label {
display: block;
font-size: 11px;
color: var(--color-text-secondary);
text-transform: uppercase;
letter-spacing: 0.5px;
margin-top: 4px;
}
.sidebar-section {
padding: 16px;
flex: 1;
overflow-y: auto;
}
.section-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 12px;
}
.section-header h4 {
font-size: 12px;
font-weight: 600;
color: var(--color-text-secondary);
text-transform: uppercase;
letter-spacing: 0.5px;
margin: 0;
}
.link-btn {
padding: 4px 8px;
border: none;
background: transparent;
color: var(--color-primary);
font-size: 12px;
cursor: pointer;
}
.link-btn:hover {
text-decoration: underline;
}
.empty-section {
padding: 16px;
text-align: center;
color: var(--color-text-secondary);
font-style: italic;
font-size: 13px;
}
.task-preview-list {
list-style: none;
margin: 0;
padding: 0;
}
.task-preview-item {
display: flex;
align-items: flex-start;
gap: 8px;
padding: 8px 0;
border-bottom: 1px solid var(--color-border);
}
.task-preview-item:last-child {
border-bottom: none;
}
.task-checkbox {
color: var(--color-text-secondary);
flex-shrink: 0;
}
.task-text {
font-size: 13px;
color: var(--color-text);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.task-more {
padding: 8px 0;
font-size: 12px;
color: var(--color-text-secondary);
font-style: italic;
}
</style>

View File

@@ -0,0 +1,156 @@
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { useTasksStore } from '../stores'
const router = useRouter()
const tasksStore = useTasksStore()
const taskFilter = ref<'all' | 'pending' | 'completed'>('pending')
const filteredTasks = computed(() => {
const tasks = tasksStore.allTasks
if (taskFilter.value === 'all') return tasks
if (taskFilter.value === 'pending') return tasks.filter(t => !t.completed)
return tasks.filter(t => t.completed)
})
function goToTask(task: { id: string; project_id: string }) {
router.push({
name: 'project-tasks',
params: { id: task.project_id, taskId: task.id }
})
}
async function toggleTask(task: { id: string; project_id: string }) {
try {
await tasksStore.toggleTask(task.project_id, task.id)
// Refresh global task list
await tasksStore.loadAllTasks()
} catch {
// Error handled in store
}
}
onMounted(() => {
tasksStore.loadAllTasks()
})
</script>
<template>
<div class="tasks-panel">
<div class="task-filters">
<button :class="{ active: taskFilter === 'pending' }" @click="taskFilter = 'pending'">
Pending
</button>
<button :class="{ active: taskFilter === 'all' }" @click="taskFilter = 'all'">
All
</button>
<button :class="{ active: taskFilter === 'completed' }" @click="taskFilter = 'completed'">
Done
</button>
</div>
<div v-if="tasksStore.loading" class="loading">Loading tasks...</div>
<div v-else-if="filteredTasks.length === 0" class="no-tasks">No tasks</div>
<div v-else class="task-list">
<div
v-for="task in filteredTasks"
:key="task.id"
class="task-item"
:class="{ completed: task.completed }"
@click="goToTask(task)"
>
<button class="task-checkbox" @click.stop="toggleTask(task)">
{{ task.completed ? '' : '' }}
</button>
<span class="task-text">{{ task.title }}</span>
<span class="task-source">
{{ task.project_id }}
</span>
</div>
</div>
</div>
</template>
<style scoped>
.tasks-panel {
flex: 1;
overflow: hidden;
display: flex;
flex-direction: column;
}
.task-filters {
padding: 8px 12px;
display: flex;
gap: 4px;
border-bottom: 1px solid var(--color-border);
}
.task-filters button {
padding: 4px 8px;
font-size: 12px;
}
.task-filters button.active {
background: var(--color-primary);
color: white;
}
.task-list {
flex: 1;
overflow-y: auto;
padding: 8px;
}
.task-item {
padding: 8px;
border-radius: 4px;
display: flex;
align-items: flex-start;
gap: 8px;
font-size: 13px;
cursor: pointer;
}
.task-item:hover {
background: var(--color-border);
}
.task-item.completed {
opacity: 0.6;
}
.task-item.completed .task-text {
text-decoration: line-through;
}
.task-checkbox {
flex-shrink: 0;
padding: 0;
border: none;
background: transparent;
cursor: pointer;
font-size: 14px;
}
.task-text {
flex: 1;
}
.task-source {
font-size: 11px;
color: var(--color-text-secondary);
}
.task-source:hover {
color: var(--color-primary);
}
.no-tasks,
.loading {
padding: 16px;
text-align: center;
color: var(--color-text-secondary);
}
</style>

View File

@@ -0,0 +1,316 @@
<script setup lang="ts">
import { ref, computed } from 'vue'
import { useRouter } from 'vue-router'
import { useProjectsStore, useWorkspaceStore, useUiStore, useThemeStore } from '../stores'
const router = useRouter()
const projectsStore = useProjectsStore()
const workspaceStore = useWorkspaceStore()
const uiStore = useUiStore()
const themeStore = useThemeStore()
const isDark = computed(() => themeStore.getEffectiveTheme() === 'dark')
const dropdownOpen = ref(false)
const showNewProjectInput = ref(false)
const newProjectName = ref('')
const activeProject = computed(() => workspaceStore.activeProject)
const projects = computed(() => projectsStore.sortedProjects)
function toggleDropdown() {
dropdownOpen.value = !dropdownOpen.value
showNewProjectInput.value = false
}
function closeDropdown() {
dropdownOpen.value = false
showNewProjectInput.value = false
newProjectName.value = ''
}
async function selectProject(projectId: string) {
await workspaceStore.setActiveProject(projectId)
closeDropdown()
router.push({ name: 'project', params: { id: projectId } })
}
function showCreateProject() {
showNewProjectInput.value = true
}
async function createProject() {
if (!newProjectName.value.trim()) return
try {
const project = await projectsStore.createProject(newProjectName.value.trim())
await workspaceStore.setActiveProject(project.id)
closeDropdown()
router.push({ name: 'project', params: { id: project.id } })
} catch {
// Error handled in store
}
}
function goToProjects() {
closeDropdown()
router.push({ name: 'projects' })
}
function goHome() {
router.push({ name: 'home' })
}
</script>
<template>
<header class="topbar">
<div class="topbar-left">
<h1 class="app-title" @click="goHome" style="cursor: pointer" title="Dashboard">Ironpad</h1>
<div class="project-selector">
<button class="project-button" @click="toggleDropdown">
<span class="project-name">
{{ activeProject?.name ?? 'Select Project' }}
</span>
<span class="dropdown-arrow">{{ dropdownOpen ? '▲' : '▼' }}</span>
</button>
<div v-if="dropdownOpen" class="dropdown-menu" @click.stop>
<div v-if="!showNewProjectInput" class="dropdown-content">
<div
v-for="project in projects"
:key="project.id"
:class="['dropdown-item', { active: project.id === activeProject?.id }]"
@click="selectProject(project.id)"
>
{{ project.name }}
</div>
<div v-if="projects.length === 0" class="dropdown-empty">
No projects yet
</div>
<div class="dropdown-divider"></div>
<div class="dropdown-item action" @click="showCreateProject">
+ New Project
</div>
<div class="dropdown-item action" @click="goToProjects">
Manage Projects
</div>
</div>
<div v-else class="new-project-form">
<input
v-model="newProjectName"
type="text"
placeholder="Project name..."
class="new-project-input"
@keyup.enter="createProject"
@keyup.escape="closeDropdown"
autofocus
/>
<div class="form-buttons">
<button class="primary" @click="createProject" :disabled="!newProjectName.trim()">
Create
</button>
<button @click="closeDropdown">Cancel</button>
</div>
</div>
</div>
</div>
</div>
<div class="topbar-right">
<button
@click="uiStore.toggleSearch()"
:class="{ active: uiStore.showSearch }"
title="Search (Ctrl+K)"
>
Search
</button>
<button
@click="uiStore.toggleTasks()"
:class="{ active: uiStore.showTasks }"
title="Tasks"
>
Tasks
</button>
<button
class="theme-toggle"
@click="themeStore.toggleTheme()"
:title="isDark ? 'Switch to light mode' : 'Switch to dark mode'"
>
{{ isDark ? '☀️' : '🌙' }}
</button>
</div>
</header>
<!-- Click outside to close dropdown -->
<div v-if="dropdownOpen" class="dropdown-overlay" @click="closeDropdown"></div>
</template>
<style scoped>
.topbar {
height: var(--header-height);
background: var(--color-bg-secondary);
border-bottom: 1px solid var(--color-border);
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 20px;
position: relative;
z-index: 100;
}
.topbar-left {
display: flex;
align-items: center;
gap: 28px;
}
.app-title {
font-size: 17px;
font-weight: 600;
margin: 0;
color: var(--color-text);
}
.project-selector {
position: relative;
}
.project-button {
display: flex;
align-items: center;
gap: 12px;
padding: 8px 14px;
background: var(--color-bg);
border: 1px solid var(--color-border);
border-radius: 6px;
cursor: pointer;
min-width: 180px;
max-width: 280px;
justify-content: space-between;
}
.project-button:hover {
border-color: var(--color-primary);
}
.project-name {
font-weight: 500;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.dropdown-arrow {
font-size: 10px;
color: var(--color-text-secondary);
}
.dropdown-menu {
position: absolute;
top: 100%;
left: 0;
margin-top: 4px;
min-width: 220px;
background: var(--color-bg);
border: 1px solid var(--color-border);
border-radius: 8px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
z-index: 200;
}
.dropdown-content {
padding: 8px 0;
}
.dropdown-item {
padding: 10px 16px;
cursor: pointer;
transition: background 0.15s;
}
.dropdown-item:hover {
background: var(--color-bg-secondary);
}
.dropdown-item.active {
background: var(--color-primary);
color: white;
}
.dropdown-item.action {
color: var(--color-primary);
font-weight: 500;
}
.dropdown-divider {
height: 1px;
background: var(--color-border);
margin: 8px 0;
}
.dropdown-empty {
padding: 16px;
text-align: center;
color: var(--color-text-secondary);
font-style: italic;
}
.new-project-form {
padding: 12px;
}
.new-project-input {
width: 100%;
padding: 8px 12px;
border: 1px solid var(--color-border);
border-radius: 6px;
background: var(--color-bg);
color: var(--color-text);
font-size: 14px;
margin-bottom: 12px;
}
.new-project-input:focus {
outline: none;
border-color: var(--color-primary);
}
.form-buttons {
display: flex;
gap: 8px;
justify-content: flex-end;
}
.topbar-right {
display: flex;
gap: 10px;
}
.topbar-right button {
padding: 8px 16px;
font-size: 13px;
}
.topbar-right button.active {
background: var(--color-primary);
color: white;
border-color: var(--color-primary);
}
.theme-toggle {
font-size: 16px;
padding: 8px 12px !important;
}
.dropdown-overlay {
position: fixed;
inset: 0;
z-index: 99;
}
</style>

View File

@@ -0,0 +1,148 @@
import { ref, onMounted, onUnmounted } from 'vue'
import type { WsMessage, WsConnectedPayload } from '../types'
export interface UseWebSocketOptions {
onFileCreated?: (path: string) => void
onFileModified?: (path: string) => void
onFileDeleted?: (path: string) => void
onFileRenamed?: (from: string, to: string) => void
onFileLocked?: (path: string, clientId: string, lockType: string) => void
onFileUnlocked?: (path: string) => void
onGitConflict?: (files: string[]) => void
}
export function useWebSocket(options: UseWebSocketOptions = {}) {
const connected = ref(false)
const clientId = ref<string | null>(null)
let ws: WebSocket | null = null
let reconnectTimeout: number | null = null
let reconnectAttempts = 0
const MAX_RECONNECT_DELAY = 30000 // 30 seconds max
function connect() {
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'
const wsUrl = `${protocol}//${window.location.host}/ws`
ws = new WebSocket(wsUrl)
ws.onopen = () => {
connected.value = true
reconnectAttempts = 0 // Reset backoff on successful connection
console.log('WebSocket connected')
}
ws.onmessage = (event) => {
try {
const msg = JSON.parse(event.data) as WsMessage
handleMessage(msg)
} catch (e) {
console.error('Failed to parse WebSocket message:', e)
}
}
ws.onclose = () => {
connected.value = false
clientId.value = null
// Exponential backoff: 1s, 2s, 4s, 8s, 16s, 30s max
const delay = Math.min(1000 * Math.pow(2, reconnectAttempts), MAX_RECONNECT_DELAY)
reconnectAttempts++
console.log(`WebSocket disconnected, reconnecting in ${delay / 1000}s...`)
reconnectTimeout = window.setTimeout(connect, delay)
}
ws.onerror = (e) => {
console.error('WebSocket error:', e)
}
}
function handleMessage(msg: WsMessage) {
switch (msg.type) {
case 'Connected': {
const payload = msg.payload as WsConnectedPayload
clientId.value = payload.client_id
console.log('WebSocket client ID:', payload.client_id)
break
}
case 'FileCreated': {
const payload = msg.payload as { path: string }
options.onFileCreated?.(payload.path)
break
}
case 'FileModified': {
const payload = msg.payload as { path: string }
options.onFileModified?.(payload.path)
break
}
case 'FileDeleted': {
const payload = msg.payload as { path: string }
options.onFileDeleted?.(payload.path)
break
}
case 'FileRenamed': {
const payload = msg.payload as { from: string; to: string }
options.onFileRenamed?.(payload.from, payload.to)
break
}
case 'FileLocked': {
const payload = msg.payload as { path: string; client_id: string; lock_type: string }
options.onFileLocked?.(payload.path, payload.client_id, payload.lock_type)
break
}
case 'FileUnlocked': {
const payload = msg.payload as { path: string }
options.onFileUnlocked?.(payload.path)
break
}
case 'GitConflict': {
const payload = msg.payload as { files: string[] }
options.onGitConflict?.(payload.files)
break
}
case 'Ping':
// Heartbeat, no action needed
break
}
}
function send(message: object) {
if (ws && ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify(message))
}
}
function lockFile(path: string, lockType: 'editor' | 'task_view') {
send({ type: 'lock_file', path, lock_type: lockType })
}
function unlockFile(path: string) {
send({ type: 'unlock_file', path })
}
function disconnect() {
if (reconnectTimeout) {
clearTimeout(reconnectTimeout)
reconnectTimeout = null
}
if (ws) {
ws.close()
ws = null
}
}
onMounted(() => {
connect()
})
onUnmounted(() => {
disconnect()
})
return {
connected,
clientId,
send,
lockFile,
unlockFile,
disconnect
}
}

12
frontend/src/main.ts Normal file
View File

@@ -0,0 +1,12 @@
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import router from './router'
import App from './App.vue'
import './style.css'
const app = createApp(App)
app.use(createPinia())
app.use(router)
app.mount('#app')

View File

@@ -0,0 +1,53 @@
import { createRouter, createWebHistory } from 'vue-router'
const router = createRouter({
history: createWebHistory(),
routes: [
{
path: '/',
name: 'home',
component: () => import('../views/DashboardView.vue')
},
{
path: '/projects',
name: 'projects',
component: () => import('../views/ProjectsView.vue')
},
{
path: '/projects/:id',
name: 'project',
component: () => import('../views/ProjectView.vue'),
props: true
},
{
path: '/projects/:id/notes/:noteId?',
name: 'project-notes',
component: () => import('../views/ProjectNotesView.vue'),
props: true
},
{
path: '/projects/:id/tasks/:taskId?',
name: 'project-tasks',
component: () => import('../views/TasksView.vue'),
props: true
},
{
path: '/calendar',
name: 'calendar',
component: () => import('../views/CalendarView.vue')
},
{
path: '/daily',
name: 'daily',
component: () => import('../views/DailyView.vue')
},
{
path: '/daily/:date',
name: 'daily-note',
component: () => import('../views/DailyView.vue'),
props: true
}
]
})
export default router

218
frontend/src/stores/git.ts Normal file
View File

@@ -0,0 +1,218 @@
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import type { GitStatus, CommitInfo, CommitDetail, DiffInfo, RemoteInfo } from '../types'
import { gitApi } from '../api/client'
export const useGitStore = defineStore('git', () => {
// State
const status = ref<GitStatus | null>(null)
const loading = ref(false)
const committing = ref(false)
const pushing = ref(false)
const fetching = ref(false)
const error = ref<string | null>(null)
const conflicts = ref<string[]>([])
// New state for expanded git features
const history = ref<CommitDetail[]>([])
const historyLoading = ref(false)
const workingDiff = ref<DiffInfo | null>(null)
const diffLoading = ref(false)
const selectedCommitDiff = ref<DiffInfo | null>(null)
const selectedCommitId = ref<string | null>(null)
const remote = ref<RemoteInfo | null>(null)
const panelOpen = ref(false)
// Getters
const hasChanges = computed(() => status.value?.has_changes ?? false)
const hasConflicts = computed(() => conflicts.value.length > 0)
const branch = computed(() => status.value?.branch ?? 'main')
const isRepo = computed(() => status.value?.is_repo ?? false)
const changedFilesCount = computed(() => status.value?.files.length ?? 0)
const hasRemote = computed(() => remote.value !== null)
const canPush = computed(() => (remote.value?.ahead ?? 0) > 0)
const canPull = computed(() => (remote.value?.behind ?? 0) > 0)
// Actions
async function loadStatus() {
try {
loading.value = true
error.value = null
status.value = await gitApi.status()
} catch (err) {
error.value = `Failed to load git status: ${err}`
} finally {
loading.value = false
}
}
async function commit(message?: string): Promise<CommitInfo | null> {
try {
committing.value = true
error.value = null
const result = await gitApi.commit(message)
await loadStatus()
// Refresh history and diff after commit
await Promise.all([loadHistory(), loadWorkingDiff(), loadRemote()])
return result
} catch (err) {
error.value = `Commit failed: ${err}`
return null
} finally {
committing.value = false
}
}
async function push() {
try {
pushing.value = true
error.value = null
const result = await gitApi.push()
if (!result.success) {
throw new Error(result.message)
}
await Promise.all([loadStatus(), loadRemote()])
} catch (err) {
error.value = `Push failed: ${err}`
throw err
} finally {
pushing.value = false
}
}
async function fetchRemote() {
try {
fetching.value = true
error.value = null
const result = await gitApi.fetch()
if (!result.success) {
throw new Error(result.message)
}
await loadRemote()
} catch (err) {
error.value = `Fetch failed: ${err}`
throw err
} finally {
fetching.value = false
}
}
async function checkConflicts() {
try {
error.value = null
conflicts.value = await gitApi.conflicts()
} catch (err) {
// Conflicts endpoint might not exist yet, ignore error
conflicts.value = []
}
}
async function loadHistory(limit?: number) {
try {
historyLoading.value = true
history.value = await gitApi.log(limit)
} catch (err) {
console.error('Failed to load git history:', err)
history.value = []
} finally {
historyLoading.value = false
}
}
async function loadWorkingDiff() {
try {
diffLoading.value = true
workingDiff.value = await gitApi.diff()
} catch (err) {
console.error('Failed to load working diff:', err)
workingDiff.value = null
} finally {
diffLoading.value = false
}
}
async function loadCommitDiff(commitId: string) {
try {
diffLoading.value = true
selectedCommitId.value = commitId
selectedCommitDiff.value = await gitApi.commitDiff(commitId)
} catch (err) {
console.error('Failed to load commit diff:', err)
selectedCommitDiff.value = null
} finally {
diffLoading.value = false
}
}
async function loadRemote() {
try {
remote.value = await gitApi.remote()
} catch (err) {
console.error('Failed to load remote info:', err)
remote.value = null
}
}
function clearSelectedCommit() {
selectedCommitId.value = null
selectedCommitDiff.value = null
}
function togglePanel() {
panelOpen.value = !panelOpen.value
if (panelOpen.value) {
// Load all data when opening panel
Promise.all([
loadStatus(),
loadHistory(),
loadWorkingDiff(),
loadRemote()
])
}
}
function clearError() {
error.value = null
}
return {
// State
status,
loading,
committing,
pushing,
fetching,
error,
conflicts,
history,
historyLoading,
workingDiff,
diffLoading,
selectedCommitDiff,
selectedCommitId,
remote,
panelOpen,
// Getters
hasChanges,
hasConflicts,
branch,
isRepo,
changedFilesCount,
hasRemote,
canPush,
canPull,
// Actions
loadStatus,
commit,
push,
fetchRemote,
checkConflicts,
loadHistory,
loadWorkingDiff,
loadCommitDiff,
loadRemote,
clearSelectedCommit,
togglePanel,
clearError
}
})

View File

@@ -0,0 +1,8 @@
export { useNotesStore } from './notes'
export { useProjectsStore } from './projects'
export { useTasksStore } from './tasks'
export { useUiStore } from './ui'
export { useWebSocketStore } from './websocket'
export { useGitStore } from './git'
export { useWorkspaceStore } from './workspace'
export { useThemeStore } from './theme'

View File

@@ -0,0 +1,131 @@
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import type { Note, NoteSummary } from '../types'
import { notesApi } from '../api/client'
export const useNotesStore = defineStore('notes', () => {
// State
const notes = ref<NoteSummary[]>([])
const currentNote = ref<Note | null>(null)
const loading = ref(false)
const loadingNote = ref(false)
const error = ref<string | null>(null)
const saveStatus = ref<'idle' | 'saving' | 'saved' | 'error'>('idle')
// Getters
const sortedNotes = computed(() =>
[...notes.value].sort((a, b) => {
const dateA = a.updated ? new Date(a.updated).getTime() : 0
const dateB = b.updated ? new Date(b.updated).getTime() : 0
return dateB - dateA
})
)
const getNoteById = computed(() => (id: string) =>
notes.value.find(n => n.id === id)
)
// Actions
async function loadNotes() {
try {
loading.value = true
error.value = null
notes.value = await notesApi.list()
} catch (err) {
error.value = `Failed to load notes: ${err}`
} finally {
loading.value = false
}
}
async function loadNote(id: string) {
try {
loadingNote.value = true
error.value = null
currentNote.value = await notesApi.get(id)
saveStatus.value = 'idle'
} catch (err) {
error.value = `Failed to load note: ${err}`
currentNote.value = null
} finally {
loadingNote.value = false
}
}
async function createNote() {
try {
error.value = null
const newNote = await notesApi.create()
await loadNotes()
return newNote
} catch (err) {
error.value = `Failed to create note: ${err}`
throw err
}
}
async function saveNote(content: string) {
if (!currentNote.value) return
try {
saveStatus.value = 'saving'
currentNote.value = await notesApi.update(currentNote.value.id, content)
saveStatus.value = 'saved'
await loadNotes() // Refresh list to update timestamps
setTimeout(() => {
if (saveStatus.value === 'saved') saveStatus.value = 'idle'
}, 2000)
} catch (err) {
saveStatus.value = 'error'
error.value = `Failed to save note: ${err}`
throw err
}
}
async function deleteNote(id?: string) {
const noteId = id || currentNote.value?.id
if (!noteId) return
try {
error.value = null
await notesApi.delete(noteId)
if (currentNote.value?.id === noteId) {
currentNote.value = null
}
await loadNotes()
} catch (err) {
error.value = `Failed to archive note: ${err}`
throw err
}
}
function clearCurrentNote() {
currentNote.value = null
saveStatus.value = 'idle'
}
function clearError() {
error.value = null
}
return {
// State
notes,
currentNote,
loading,
loadingNote,
error,
saveStatus,
// Getters
sortedNotes,
getNoteById,
// Actions
loadNotes,
loadNote,
createNote,
saveNote,
deleteNote,
clearCurrentNote,
clearError
}
})

View File

@@ -0,0 +1,84 @@
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import type { Project } from '../types'
import { projectsApi } from '../api/client'
export const useProjectsStore = defineStore('projects', () => {
// State
const projects = ref<Project[]>([])
const currentProject = ref<Project | null>(null)
const loading = ref(false)
const error = ref<string | null>(null)
// Getters
const sortedProjects = computed(() =>
[...projects.value].sort((a, b) => a.name.localeCompare(b.name))
)
const getProjectById = computed(() => (id: string) =>
projects.value.find(p => p.id === id)
)
// Actions
async function loadProjects() {
try {
loading.value = true
error.value = null
projects.value = await projectsApi.list()
} catch (err) {
error.value = `Failed to load projects: ${err}`
} finally {
loading.value = false
}
}
async function loadProject(id: string) {
try {
loading.value = true
error.value = null
currentProject.value = await projectsApi.get(id)
} catch (err) {
error.value = `Failed to load project: ${err}`
currentProject.value = null
} finally {
loading.value = false
}
}
async function createProject(name: string) {
try {
error.value = null
const newProject = await projectsApi.create(name)
await loadProjects()
return newProject
} catch (err) {
error.value = `Failed to create project: ${err}`
throw err
}
}
function clearCurrentProject() {
currentProject.value = null
}
function clearError() {
error.value = null
}
return {
// State
projects,
currentProject,
loading,
error,
// Getters
sortedProjects,
getProjectById,
// Actions
loadProjects,
loadProject,
createProject,
clearCurrentProject,
clearError
}
})

View File

@@ -0,0 +1,235 @@
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import type { Task, TaskWithContent } from '../types'
import { tasksApi } from '../api/client'
export const useTasksStore = defineStore('tasks', () => {
// State
const tasks = ref<Task[]>([])
const allTasks = ref<Task[]>([])
const currentProjectId = ref<string | null>(null)
const selectedTask = ref<TaskWithContent | null>(null)
const loading = ref(false)
const error = ref<string | null>(null)
// Getters
const activeTasks = computed(() =>
tasks.value.filter(t => !t.completed && t.section !== 'Backlog')
)
const completedTasks = computed(() =>
tasks.value.filter(t => t.completed || t.section === 'Completed')
)
const backlogTasks = computed(() =>
tasks.value.filter(t => !t.completed && t.section === 'Backlog')
)
const pendingTasks = computed(() =>
allTasks.value.filter(t => !t.completed)
)
const tasksByProject = computed(() => (projectId: string) =>
allTasks.value.filter(t => t.project_id === projectId)
)
/** All unique tags used across tasks in the current project */
const projectTags = computed(() => {
const tagSet = new Set<string>()
for (const task of tasks.value) {
if (task.tags) {
for (const tag of task.tags) {
tagSet.add(tag)
}
}
}
return [...tagSet].sort()
})
// Actions
async function loadAllTasks() {
try {
loading.value = true
error.value = null
allTasks.value = await tasksApi.listAll()
} catch (err) {
error.value = `Failed to load tasks: ${err}`
} finally {
loading.value = false
}
}
async function loadProjectTasks(projectId: string) {
try {
loading.value = true
error.value = null
currentProjectId.value = projectId
tasks.value = await tasksApi.list(projectId)
} catch (err) {
error.value = `Failed to load project tasks: ${err}`
} finally {
loading.value = false
}
}
async function loadTask(projectId: string, taskId: string) {
try {
error.value = null
selectedTask.value = await tasksApi.get(projectId, taskId)
} catch (err) {
error.value = `Failed to load task: ${err}`
selectedTask.value = null
}
}
async function createTask(projectId: string, title: string, section?: string, parentId?: string) {
try {
error.value = null
const task = await tasksApi.create(projectId, title, section, parentId)
// Refresh tasks list
if (currentProjectId.value === projectId) {
await loadProjectTasks(projectId)
}
return task
} catch (err) {
error.value = `Failed to create task: ${err}`
throw err
}
}
async function updateTaskContent(projectId: string, taskId: string, content: string) {
try {
error.value = null
const task = await tasksApi.updateContent(projectId, taskId, content)
selectedTask.value = task
// Refresh tasks list to update timestamps
if (currentProjectId.value === projectId) {
await loadProjectTasks(projectId)
}
return task
} catch (err) {
error.value = `Failed to update task: ${err}`
throw err
}
}
async function toggleTask(projectId: string, taskId: string) {
try {
error.value = null
await tasksApi.toggle(projectId, taskId)
// Refresh tasks
if (currentProjectId.value === projectId) {
await loadProjectTasks(projectId)
}
// Update selected task if it's the one being toggled
if (selectedTask.value?.id === taskId) {
await loadTask(projectId, taskId)
}
} catch (err) {
error.value = `Failed to toggle task: ${err}`
throw err
}
}
async function updateTaskMeta(
projectId: string,
taskId: string,
meta: { title?: string; section?: string; priority?: string; due_date?: string; is_active?: boolean; tags?: string[]; recurrence?: string; recurrence_interval?: number }
) {
try {
error.value = null
await tasksApi.updateMeta(projectId, taskId, meta)
// Refresh tasks
if (currentProjectId.value === projectId) {
await loadProjectTasks(projectId)
}
// Update selected task if it's the one being updated
if (selectedTask.value?.id === taskId) {
await loadTask(projectId, taskId)
}
} catch (err) {
error.value = `Failed to update task: ${err}`
throw err
}
}
async function deleteTask(projectId: string, taskId: string) {
try {
error.value = null
await tasksApi.delete(projectId, taskId)
// Clear selected task if it was deleted
if (selectedTask.value?.id === taskId) {
selectedTask.value = null
}
// Refresh tasks
if (currentProjectId.value === projectId) {
await loadProjectTasks(projectId)
}
} catch (err) {
error.value = `Failed to delete task: ${err}`
throw err
}
}
function selectTask(task: Task | null) {
if (task && currentProjectId.value) {
loadTask(currentProjectId.value, task.id)
} else {
selectedTask.value = null
}
}
function clearSelectedTask() {
selectedTask.value = null
}
function clearProjectTasks() {
tasks.value = []
currentProjectId.value = null
selectedTask.value = null
}
function clearError() {
error.value = null
}
return {
// State
tasks,
allTasks,
currentProjectId,
selectedTask,
loading,
error,
// Getters
activeTasks,
completedTasks,
backlogTasks,
pendingTasks,
tasksByProject,
projectTags,
// Actions
loadAllTasks,
loadProjectTasks,
loadTask,
createTask,
updateTaskContent,
toggleTask,
updateTaskMeta,
deleteTask,
selectTask,
clearSelectedTask,
clearProjectTasks,
clearError
}
})

View File

@@ -0,0 +1,76 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
export type ThemeMode = 'dark' | 'light' | 'system'
const STORAGE_KEY = 'ironpad-theme'
export const useThemeStore = defineStore('theme', () => {
// Default to dark mode
const mode = ref<ThemeMode>('dark')
// Load saved preference
function loadSavedTheme() {
const saved = localStorage.getItem(STORAGE_KEY)
if (saved && ['dark', 'light', 'system'].includes(saved)) {
mode.value = saved as ThemeMode
}
}
// Get the effective theme (resolves 'system' to actual theme)
function getEffectiveTheme(): 'dark' | 'light' {
if (mode.value === 'system') {
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'
}
return mode.value
}
// Apply theme to document
function applyTheme() {
const effectiveTheme = getEffectiveTheme()
document.documentElement.setAttribute('data-theme', effectiveTheme)
// Also set class for easier CSS targeting
document.documentElement.classList.remove('theme-dark', 'theme-light')
document.documentElement.classList.add(`theme-${effectiveTheme}`)
}
// Set theme mode
function setTheme(newMode: ThemeMode) {
mode.value = newMode
localStorage.setItem(STORAGE_KEY, newMode)
applyTheme()
}
// Toggle between dark and light
function toggleTheme() {
const current = getEffectiveTheme()
setTheme(current === 'dark' ? 'light' : 'dark')
}
// Initialize
function init() {
loadSavedTheme()
// Ensure data-theme attribute is set (even if same as default)
applyTheme()
// Listen for system theme changes when in system mode
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', () => {
if (mode.value === 'system') {
applyTheme()
}
})
// Theme is now initialized
}
return {
mode,
getEffectiveTheme,
setTheme,
toggleTheme,
applyTheme,
init
}
})

125
frontend/src/stores/ui.ts Normal file
View File

@@ -0,0 +1,125 @@
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import type { SearchResult } from '../types'
import { searchApi } from '../api/client'
export const useUiStore = defineStore('ui', () => {
// State
const showSearch = ref(false)
const showTasks = ref(false)
const showPreview = ref(false)
const searchQuery = ref('')
const searchResults = ref<SearchResult[]>([])
const isSearching = ref(false)
const globalError = ref<string | null>(null)
const sidebarSection = ref<'notes' | 'projects' | 'daily'>('notes')
// Getters
const hasSearchResults = computed(() => searchResults.value.length > 0)
// Actions
function openSearch() {
showSearch.value = true
showTasks.value = false
}
function closeSearch() {
showSearch.value = false
searchQuery.value = ''
searchResults.value = []
}
function toggleSearch() {
if (showSearch.value) {
closeSearch()
} else {
openSearch()
}
}
function openTasks() {
showTasks.value = true
showSearch.value = false
}
function closeTasks() {
showTasks.value = false
}
function toggleTasks() {
if (showTasks.value) {
closeTasks()
} else {
openTasks()
}
}
function togglePreview() {
showPreview.value = !showPreview.value
// Persist preference
localStorage.setItem('ironpad-show-preview', String(showPreview.value))
}
function loadPreviewPreference() {
const saved = localStorage.getItem('ironpad-show-preview')
if (saved !== null) {
showPreview.value = saved === 'true'
}
}
async function search(query: string) {
if (query.length < 2) {
searchResults.value = []
return
}
try {
isSearching.value = true
searchQuery.value = query
searchResults.value = await searchApi.search(query)
} catch (err) {
globalError.value = `Search failed: ${err}`
} finally {
isSearching.value = false
}
}
function setSidebarSection(section: 'notes' | 'projects' | 'daily') {
sidebarSection.value = section
}
function setGlobalError(message: string | null) {
globalError.value = message
}
function clearGlobalError() {
globalError.value = null
}
return {
// State
showSearch,
showTasks,
showPreview,
searchQuery,
searchResults,
isSearching,
globalError,
sidebarSection,
// Getters
hasSearchResults,
// Actions
openSearch,
closeSearch,
toggleSearch,
openTasks,
closeTasks,
toggleTasks,
togglePreview,
loadPreviewPreference,
search,
setSidebarSection,
setGlobalError,
clearGlobalError
}
})

View File

@@ -0,0 +1,67 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
import type { FileLock } from '../types'
export const useWebSocketStore = defineStore('websocket', () => {
// State
const connected = ref(false)
const clientId = ref<string | null>(null)
const fileLocks = ref<Map<string, FileLock>>(new Map())
const gitConflicts = ref<string[]>([])
// Actions
function setConnected(value: boolean) {
connected.value = value
}
function setClientId(id: string | null) {
clientId.value = id
}
function addFileLock(lock: FileLock) {
fileLocks.value.set(lock.path, lock)
}
function removeFileLock(path: string) {
fileLocks.value.delete(path)
}
function isFileLocked(path: string): FileLock | undefined {
return fileLocks.value.get(path)
}
function isFileLockedByOther(path: string): boolean {
const lock = fileLocks.value.get(path)
return lock !== undefined && lock.client_id !== clientId.value
}
function setGitConflicts(files: string[]) {
gitConflicts.value = files
}
function clearGitConflicts() {
gitConflicts.value = []
}
function clearAllLocks() {
fileLocks.value.clear()
}
return {
// State
connected,
clientId,
fileLocks,
gitConflicts,
// Actions
setConnected,
setClientId,
addFileLock,
removeFileLock,
isFileLocked,
isFileLockedByOther,
setGitConflicts,
clearGitConflicts,
clearAllLocks
}
})

View File

@@ -0,0 +1,76 @@
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import type { Project } from '../types'
import { projectsApi } from '../api/client'
const STORAGE_KEY = 'ironpad-active-project'
export const useWorkspaceStore = defineStore('workspace', () => {
// State
const activeProjectId = ref<string | null>(null)
const activeProject = ref<Project | null>(null)
const loading = ref(false)
const error = ref<string | null>(null)
// Getters
const hasActiveProject = computed(() => activeProjectId.value !== null)
// Actions
async function setActiveProject(projectId: string | null) {
if (projectId === activeProjectId.value) return
activeProjectId.value = projectId
if (projectId) {
// Persist to localStorage
localStorage.setItem(STORAGE_KEY, projectId)
// Load project details
try {
loading.value = true
error.value = null
activeProject.value = await projectsApi.get(projectId)
} catch (err) {
error.value = `Failed to load project: ${err}`
activeProject.value = null
} finally {
loading.value = false
}
} else {
localStorage.removeItem(STORAGE_KEY)
activeProject.value = null
}
}
async function loadSavedProject() {
const savedId = localStorage.getItem(STORAGE_KEY)
if (savedId) {
await setActiveProject(savedId)
}
}
function clearActiveProject() {
activeProjectId.value = null
activeProject.value = null
localStorage.removeItem(STORAGE_KEY)
}
function clearError() {
error.value = null
}
return {
// State
activeProjectId,
activeProject,
loading,
error,
// Getters
hasActiveProject,
// Actions
setActiveProject,
loadSavedProject,
clearActiveProject,
clearError
}
})

242
frontend/src/style.css Normal file
View File

@@ -0,0 +1,242 @@
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
/*
* Theme variables are defined in App.vue
* This file only contains component styles
*/
#app {
display: flex;
height: 100vh;
overflow: hidden;
}
/* Sidebar */
.sidebar {
width: var(--sidebar-width);
min-width: var(--sidebar-width);
background: var(--color-bg-secondary);
border-right: 1px solid var(--color-border);
display: flex;
flex-direction: column;
overflow: hidden;
}
.sidebar-header {
padding: 12px 16px;
border-bottom: 1px solid var(--color-border);
display: flex;
justify-content: space-between;
align-items: center;
}
.sidebar-header h1 {
font-size: 16px;
font-weight: 600;
margin: 0;
}
.sidebar-content {
flex: 1;
overflow-y: auto;
padding: 8px 0;
}
/* Note list */
.note-list {
list-style: none;
}
.note-item {
padding: 10px 16px;
cursor: pointer;
border-left: 3px solid transparent;
transition: background 0.15s, border-color 0.15s;
}
.note-item:hover {
background: var(--color-border);
}
.note-item.active {
background: var(--color-border);
border-left-color: var(--color-primary);
}
.note-item-title {
font-weight: 500;
margin-bottom: 2px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.note-item-meta {
font-size: 12px;
color: var(--color-text-secondary);
}
/* Main content */
.main {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
}
.main-header {
height: var(--header-height);
padding: 0 16px;
border-bottom: 1px solid var(--color-border);
display: flex;
justify-content: space-between;
align-items: center;
}
.main-header h2 {
font-size: 14px;
font-weight: 500;
margin: 0;
}
.main-content {
flex: 1;
overflow: hidden;
display: flex;
flex-direction: column;
}
/* Editor */
.editor-container {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
}
.editor {
flex: 1;
width: 100%;
padding: 16px 24px;
border: none;
resize: none;
font-family: 'SF Mono', 'Monaco', 'Inconsolata', 'Fira Code', monospace;
font-size: 14px;
line-height: 1.6;
background: var(--color-bg);
color: var(--color-text);
outline: none;
}
.editor:focus {
outline: none;
}
/* Empty state */
.empty-state {
flex: 1;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
color: var(--color-text-secondary);
padding: 32px;
}
.empty-state h2 {
margin-bottom: 8px;
font-weight: 500;
}
/* Buttons */
button {
padding: 6px 12px;
border: 1px solid var(--color-border);
border-radius: 6px;
background: var(--color-bg);
color: var(--color-text);
font-size: 13px;
cursor: pointer;
transition: background 0.15s, border-color 0.15s;
}
button:hover {
background: var(--color-bg-secondary);
border-color: var(--color-text-secondary);
}
button.primary {
background: var(--color-primary);
border-color: var(--color-primary);
color: white;
}
button.primary:hover {
opacity: 0.9;
}
button.danger {
color: var(--color-danger);
}
button.danger:hover {
background: var(--color-danger);
border-color: var(--color-danger);
color: white;
}
/* Status indicators */
.status {
font-size: 12px;
color: var(--color-text-secondary);
display: flex;
align-items: center;
gap: 6px;
}
.status.saving {
color: var(--color-primary);
}
.status.saved {
color: var(--color-success);
}
.status.error {
color: var(--color-danger);
}
/* Loading */
.loading {
padding: 16px;
color: var(--color-text-secondary);
text-align: center;
}
/* Error */
.error-message {
padding: 12px 16px;
background: var(--color-danger);
color: white;
font-size: 13px;
}
/* Button group */
.button-group {
display: flex;
gap: 8px;
}
/* Type badge */
.type-badge {
display: inline-block;
padding: 2px 6px;
border-radius: 4px;
font-size: 11px;
background: var(--color-border);
color: var(--color-text-secondary);
}

175
frontend/src/types/index.ts Normal file
View File

@@ -0,0 +1,175 @@
// Types for Ironpad
export interface NoteSummary {
id: string
title: string
path: string
note_type: string
updated?: string
}
export interface Note {
id: string
path: string
note_type: string
frontmatter: Record<string, unknown>
content: string
}
export interface Project {
id: string
name: string
path: string
created: string
}
export interface ProjectWithContent extends Project {
content: string
}
export interface ProjectNote {
id: string
title: string
path: string
project_id: string
created: string
updated: string
}
export interface ProjectNoteWithContent extends ProjectNote {
content: string
}
export interface Task {
id: string
title: string
completed: boolean
section: string
priority?: string
due_date?: string
is_active: boolean
tags: string[]
parent_id?: string
recurrence?: string
recurrence_interval?: number
project_id: string
path: string
created: string
updated: string
}
export interface TaskWithContent extends Task {
content: string
}
export interface SearchResult {
path: string
title: string
matches: { line_number: number; line_content: string }[]
}
export interface GitStatus {
is_repo: boolean
branch?: string
has_changes: boolean
files: { path: string; status: string }[]
last_commit?: { id: string; message: string; timestamp: string }
conflicts?: string[]
}
export interface CommitInfo {
id: string
message: string
timestamp: string
}
export interface CommitDetail {
id: string
short_id: string
message: string
author: string
timestamp: string
files_changed: number
}
export interface DiffLine {
origin: string
content: string
}
export interface DiffHunk {
header: string
lines: DiffLine[]
}
export interface FileDiff {
path: string
status: string
additions: number
deletions: number
hunks: DiffHunk[]
}
export interface DiffStats {
files_changed: number
insertions: number
deletions: number
}
export interface DiffInfo {
files: FileDiff[]
stats: DiffStats
}
export interface RemoteInfo {
name: string
url: string
has_upstream: boolean
ahead: number
behind: number
}
export interface DailyNote {
id: string
date: string
path: string
content: string
frontmatter: Record<string, unknown>
}
export interface FileLock {
path: string
client_id: string
lock_type: 'editor' | 'task_view'
}
// WebSocket message types
export type WsMessageType =
| 'Connected'
| 'FileCreated'
| 'FileModified'
| 'FileDeleted'
| 'FileRenamed'
| 'FileLocked'
| 'FileUnlocked'
| 'GitConflict'
| 'Ping'
export interface WsMessage {
type: WsMessageType
payload?: unknown
}
export interface WsConnectedPayload {
client_id: string
}
export interface WsFilePayload {
path: string
}
export interface WsFileLockPayload {
path: string
client_id: string
lock_type: 'editor' | 'task_view'
}

View File

@@ -0,0 +1,426 @@
<script setup lang="ts">
import { ref, computed, onMounted, watch } from 'vue'
import { useRouter } from 'vue-router'
import { useTasksStore, useProjectsStore, useWorkspaceStore } from '../stores'
import { dailyApi } from '../api/client'
import type { Task, DailyNote } from '../types'
const router = useRouter()
const tasksStore = useTasksStore()
const projectsStore = useProjectsStore()
const workspaceStore = useWorkspaceStore()
// Current month being displayed
const currentYear = ref(new Date().getFullYear())
const currentMonth = ref(new Date().getMonth()) // 0-indexed
// Daily notes dates
const dailyDates = ref<Set<string>>(new Set())
const monthNames = [
'January', 'February', 'March', 'April', 'May', 'June',
'July', 'August', 'September', 'October', 'November', 'December'
]
const dayNames = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun']
const monthLabel = computed(() => `${monthNames[currentMonth.value]} ${currentYear.value}`)
// Build calendar grid
const calendarDays = computed(() => {
const year = currentYear.value
const month = currentMonth.value
// First day of the month
const firstDay = new Date(year, month, 1)
// Day of week (0=Sun, 1=Mon...) - shift to Mon=0
let startDow = firstDay.getDay() - 1
if (startDow < 0) startDow = 6
// Days in this month
const daysInMonth = new Date(year, month + 1, 0).getDate()
// Days in previous month (for padding)
const daysInPrevMonth = new Date(year, month, 0).getDate()
const days: { date: string; day: number; isCurrentMonth: boolean; isToday: boolean }[] = []
// Previous month padding
for (let i = startDow - 1; i >= 0; i--) {
const d = daysInPrevMonth - i
const m = month === 0 ? 11 : month - 1
const y = month === 0 ? year - 1 : year
days.push({
date: formatDate(y, m, d),
day: d,
isCurrentMonth: false,
isToday: false,
})
}
// Current month
const today = new Date()
const todayStr = formatDate(today.getFullYear(), today.getMonth(), today.getDate())
for (let d = 1; d <= daysInMonth; d++) {
const dateStr = formatDate(year, month, d)
days.push({
date: dateStr,
day: d,
isCurrentMonth: true,
isToday: dateStr === todayStr,
})
}
// Next month padding (fill to 6 rows * 7 = 42 cells, or at least complete the row)
const remaining = 7 - (days.length % 7)
if (remaining < 7) {
for (let d = 1; d <= remaining; d++) {
const m = month === 11 ? 0 : month + 1
const y = month === 11 ? year + 1 : year
days.push({
date: formatDate(y, m, d),
day: d,
isCurrentMonth: false,
isToday: false,
})
}
}
return days
})
function formatDate(y: number, m: number, d: number): string {
return `${y}-${String(m + 1).padStart(2, '0')}-${String(d).padStart(2, '0')}`
}
// Tasks grouped by due date
const tasksByDate = computed(() => {
const map = new Map<string, Task[]>()
for (const task of tasksStore.allTasks) {
if (task.due_date && !task.completed) {
const existing = map.get(task.due_date) || []
existing.push(task)
map.set(task.due_date, existing)
}
}
return map
})
function getTasksForDate(dateStr: string): Task[] {
return tasksByDate.value.get(dateStr) || []
}
function hasDailyNote(dateStr: string): boolean {
return dailyDates.value.has(dateStr)
}
// Navigation
function prevMonth() {
if (currentMonth.value === 0) {
currentMonth.value = 11
currentYear.value--
} else {
currentMonth.value--
}
}
function nextMonth() {
if (currentMonth.value === 11) {
currentMonth.value = 0
currentYear.value++
} else {
currentMonth.value++
}
}
function goToToday() {
const today = new Date()
currentYear.value = today.getFullYear()
currentMonth.value = today.getMonth()
}
function clickDate(dateStr: string) {
// Navigate to daily note for this date
router.push({ name: 'daily-note', params: { date: dateStr } })
}
function clickTask(task: Task) {
workspaceStore.setActiveProject(task.project_id)
router.push({
name: 'project-tasks',
params: { id: task.project_id, taskId: task.id }
})
}
function projectName(projectId: string): string {
const p = projectsStore.getProjectById(projectId)
return p?.name || projectId
}
function formatDueClass(dateStr: string): string {
const now = new Date()
const date = new Date(dateStr)
const diff = Math.ceil((date.getTime() - now.getTime()) / (1000 * 60 * 60 * 24))
if (diff < 0) return 'overdue'
if (diff === 0) return 'today'
if (diff <= 3) return 'soon'
return ''
}
// Load data
async function loadDailyDates() {
try {
const notes: DailyNote[] = await dailyApi.list()
dailyDates.value = new Set(notes.map(n => n.date))
} catch {
// Ignore
}
}
onMounted(async () => {
await Promise.all([
tasksStore.loadAllTasks(),
projectsStore.loadProjects(),
loadDailyDates(),
])
})
</script>
<template>
<div class="calendar-view">
<!-- Header -->
<div class="calendar-header">
<div class="header-left">
<button @click="prevMonth" title="Previous month">&lsaquo;</button>
<h2>{{ monthLabel }}</h2>
<button @click="nextMonth" title="Next month">&rsaquo;</button>
<button class="small today-btn" @click="goToToday">Today</button>
</div>
</div>
<!-- Day names -->
<div class="calendar-grid day-names">
<div v-for="name in dayNames" :key="name" class="day-name">{{ name }}</div>
</div>
<!-- Calendar grid -->
<div class="calendar-grid calendar-body">
<div
v-for="(day, idx) in calendarDays"
:key="idx"
:class="['calendar-cell', {
'other-month': !day.isCurrentMonth,
'is-today': day.isToday,
'has-tasks': getTasksForDate(day.date).length > 0,
}]"
>
<div class="cell-header" @click="clickDate(day.date)">
<span class="cell-day">{{ day.day }}</span>
<span v-if="hasDailyNote(day.date)" class="daily-dot" title="Daily note"></span>
</div>
<div class="cell-tasks">
<div
v-for="task in getTasksForDate(day.date).slice(0, 3)"
:key="task.id"
:class="['cell-task', formatDueClass(day.date)]"
@click.stop="clickTask(task)"
:title="`${projectName(task.project_id)}: ${task.title}`"
>
{{ task.title }}
</div>
<div
v-if="getTasksForDate(day.date).length > 3"
class="cell-more"
@click="clickDate(day.date)"
>
+{{ getTasksForDate(day.date).length - 3 }} more
</div>
</div>
</div>
</div>
</div>
</template>
<style scoped>
.calendar-view {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
}
.calendar-header {
height: var(--header-height);
min-height: var(--header-height);
padding: 0 24px;
border-bottom: 1px solid var(--color-border);
display: flex;
align-items: center;
flex-shrink: 0;
}
.header-left {
display: flex;
align-items: center;
gap: 12px;
}
.header-left button {
font-size: 18px;
padding: 4px 10px;
line-height: 1;
}
.today-btn {
font-size: 12px !important;
padding: 4px 10px !important;
}
.calendar-header h2 {
font-size: 16px;
font-weight: 600;
margin: 0;
min-width: 180px;
text-align: center;
}
/* Grid layout */
.calendar-grid {
display: grid;
grid-template-columns: repeat(7, 1fr);
}
.day-names {
border-bottom: 1px solid var(--color-border);
flex-shrink: 0;
}
.day-name {
padding: 8px 4px;
text-align: center;
font-size: 11px;
font-weight: 600;
color: var(--color-text-secondary);
text-transform: uppercase;
letter-spacing: 0.5px;
}
.calendar-body {
flex: 1;
overflow-y: auto;
grid-auto-rows: minmax(100px, 1fr);
}
/* Calendar cells */
.calendar-cell {
border-right: 1px solid var(--color-border);
border-bottom: 1px solid var(--color-border);
padding: 4px;
min-height: 100px;
display: flex;
flex-direction: column;
overflow: hidden;
}
.calendar-cell:nth-child(7n) {
border-right: none;
}
.calendar-cell.other-month {
opacity: 0.35;
}
.calendar-cell.is-today {
background: rgba(88, 166, 255, 0.08);
}
.calendar-cell.is-today .cell-day {
background: var(--color-primary);
color: white;
border-radius: 50%;
width: 24px;
height: 24px;
display: flex;
align-items: center;
justify-content: center;
}
.cell-header {
display: flex;
align-items: center;
gap: 4px;
padding: 2px 4px;
cursor: pointer;
border-radius: 4px;
flex-shrink: 0;
}
.cell-header:hover {
background: var(--color-bg-hover);
}
.cell-day {
font-size: 13px;
font-weight: 500;
}
.daily-dot {
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--color-primary);
flex-shrink: 0;
}
/* Tasks in cells */
.cell-tasks {
flex: 1;
overflow: hidden;
display: flex;
flex-direction: column;
gap: 2px;
padding-top: 2px;
}
.cell-task {
font-size: 11px;
padding: 2px 6px;
border-radius: 4px;
background: var(--color-bg-secondary);
border-left: 2px solid var(--color-primary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
cursor: pointer;
transition: background 0.1s;
flex-shrink: 0;
}
.cell-task:hover {
background: var(--color-bg-hover);
}
.cell-task.overdue {
border-left-color: var(--color-danger);
color: var(--color-danger);
}
.cell-task.today {
border-left-color: var(--color-danger);
}
.cell-task.soon {
border-left-color: var(--color-warning);
}
.cell-more {
font-size: 10px;
color: var(--color-text-secondary);
padding: 2px 6px;
cursor: pointer;
}
.cell-more:hover {
color: var(--color-primary);
}
</style>

View File

@@ -0,0 +1,294 @@
<script setup lang="ts">
import { ref, computed, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { dailyApi } from '../api/client'
import { useGitStore } from '../stores'
import type { DailyNote } from '../types'
import MilkdownEditor from '../components/MilkdownEditor.vue'
const props = defineProps<{
date?: string
}>()
const route = useRoute()
const router = useRouter()
const gitStore = useGitStore()
const currentDate = computed((): string => {
if (props.date) return props.date
const routeDate = route.params.date
if (typeof routeDate === 'string') return routeDate
return getTodayDate()
})
// Note state
const dailyNote = ref<DailyNote | null>(null)
const editorContent = ref('')
const loading = ref(false)
const error = ref<string | null>(null)
const saveStatus = ref<'idle' | 'saving' | 'saved' | 'error'>('idle')
const noteExists = ref(false) // Track if the note file actually exists
let saveTimeout: number | null = null
// Default template for daily notes
function getDefaultTemplate(dateStr: string): string {
const date = new Date(dateStr + 'T00:00:00')
const formatted = date.toLocaleDateString(undefined, {
weekday: 'long',
year: 'numeric',
month: 'long',
day: 'numeric'
})
return `# ${formatted}
## Today's Focus
## Notes
## Tasks
- [ ]
`
}
function getTodayDate(): string {
return new Date().toISOString().split('T')[0] as string
}
function formatDateDisplay(dateStr: string): string {
const date = new Date(dateStr + 'T00:00:00')
return date.toLocaleDateString(undefined, {
weekday: 'long',
year: 'numeric',
month: 'long',
day: 'numeric'
})
}
function goToToday() {
router.push({ name: 'daily' })
}
function goToPrevDay() {
const date = new Date(currentDate.value + 'T00:00:00')
date.setDate(date.getDate() - 1)
const prevDate = date.toISOString().split('T')[0]
router.push({ name: 'daily-note', params: { date: prevDate } })
}
function goToNextDay() {
const date = new Date(currentDate.value + 'T00:00:00')
date.setDate(date.getDate() + 1)
const nextDate = date.toISOString().split('T')[0]
router.push({ name: 'daily-note', params: { date: nextDate } })
}
function scheduleAutoSave() {
if (saveTimeout) clearTimeout(saveTimeout)
saveStatus.value = 'idle'
saveTimeout = window.setTimeout(saveNote, 1000)
}
async function saveNote() {
// Don't save if content is just the template or empty
const template = getDefaultTemplate(currentDate.value)
const trimmedContent = editorContent.value.trim()
const trimmedTemplate = template.trim()
if (!trimmedContent || trimmedContent === trimmedTemplate) {
// Don't save empty/template-only content
saveStatus.value = 'idle'
return
}
try {
saveStatus.value = 'saving'
if (!noteExists.value) {
// Create the note first
dailyNote.value = await dailyApi.create(currentDate.value, editorContent.value)
noteExists.value = true
} else {
// Update existing note using the date
await dailyApi.update(currentDate.value, editorContent.value)
}
saveStatus.value = 'saved'
gitStore.loadStatus()
setTimeout(() => {
if (saveStatus.value === 'saved') saveStatus.value = 'idle'
}, 2000)
} catch (err) {
saveStatus.value = 'error'
error.value = `Failed to save: ${err}`
}
}
async function loadDailyNote() {
loading.value = true
error.value = null
noteExists.value = false
try {
// Try to get existing daily note (don't auto-create)
dailyNote.value = await dailyApi.get(currentDate.value)
editorContent.value = dailyNote.value?.content ?? ''
noteExists.value = true
} catch (err) {
// Note doesn't exist - that's fine, show template but don't create file
dailyNote.value = null
editorContent.value = getDefaultTemplate(currentDate.value)
noteExists.value = false
} finally {
loading.value = false
}
}
// Watch for content changes - auto-save
watch(editorContent, (newContent, oldContent) => {
// Only trigger auto-save if content actually changed (not on initial load)
if (oldContent !== undefined && newContent !== oldContent) {
scheduleAutoSave()
}
})
watch(currentDate, () => {
loadDailyNote()
}, { immediate: true })
</script>
<template>
<div class="daily-view">
<div class="view-header">
<div class="date-nav">
<button @click="goToPrevDay" title="Previous day"></button>
<h2>{{ formatDateDisplay(currentDate) }}</h2>
<button @click="goToNextDay" title="Next day"></button>
</div>
<div class="button-group">
<span :class="['status', saveStatus]">
<template v-if="saveStatus === 'saving'">Saving...</template>
<template v-else-if="saveStatus === 'saved'">Saved</template>
<template v-else-if="saveStatus === 'error'">Save failed</template>
</span>
<span v-if="!noteExists" class="note-status">Draft</span>
<button @click="goToToday" v-if="currentDate !== getTodayDate()">Today</button>
<button @click="saveNote" :disabled="saveStatus === 'saving'">Save</button>
</div>
</div>
<div v-if="error" class="error-message">
{{ error }}
<button @click="error = null">Dismiss</button>
</div>
<div v-if="loading" class="loading">Loading daily note...</div>
<div v-else class="view-content">
<div class="editor-container">
<MilkdownEditor
v-model="editorContent"
placeholder="What's on your mind today?"
/>
</div>
</div>
</div>
</template>
<style scoped>
.daily-view {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
}
.view-header {
height: var(--header-height);
padding: 0 16px;
border-bottom: 1px solid var(--color-border);
display: flex;
justify-content: space-between;
align-items: center;
}
.date-nav {
display: flex;
align-items: center;
gap: 12px;
}
.date-nav button {
padding: 4px 8px;
}
.view-header h2 {
font-size: 14px;
font-weight: 500;
margin: 0;
}
.button-group {
display: flex;
gap: 8px;
align-items: center;
}
.status {
font-size: 12px;
color: var(--color-text-secondary);
}
.status.saving { color: var(--color-primary); }
.status.saved { color: var(--color-success); }
.status.error { color: var(--color-danger); }
.note-status {
font-size: 11px;
padding: 2px 8px;
background: var(--color-bg-secondary);
border-radius: 4px;
color: var(--color-text-secondary);
}
.error-message {
padding: 12px 16px;
background: var(--color-danger);
color: white;
font-size: 13px;
display: flex;
justify-content: space-between;
align-items: center;
}
.error-message button {
background: transparent;
border: 1px solid white;
color: white;
}
.view-content {
flex: 1;
overflow: hidden;
display: flex;
}
.editor-container {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
}
.loading {
flex: 1;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
color: var(--color-text-secondary);
padding: 32px;
}
</style>

View File

@@ -0,0 +1,415 @@
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { useProjectsStore, useTasksStore, useWorkspaceStore } from '../stores'
import type { Task } from '../types'
const router = useRouter()
const projectsStore = useProjectsStore()
const tasksStore = useTasksStore()
const workspaceStore = useWorkspaceStore()
const loading = ref(true)
// Group tasks by project
const projectSummaries = computed(() => {
return projectsStore.sortedProjects.map(project => {
const projectTasks = tasksStore.allTasks.filter(t => t.project_id === project.id)
const active = projectTasks.filter(t => !t.completed && t.is_active)
const backlog = projectTasks.filter(t => !t.completed && !t.is_active)
const completed = projectTasks.filter(t => t.completed)
const overdue = active.filter(t => {
if (!t.due_date) return false
return new Date(t.due_date) < new Date()
})
return {
...project,
activeTasks: active,
backlogCount: backlog.length,
completedCount: completed.length,
overdueCount: overdue.length,
totalCount: projectTasks.length,
}
})
})
function formatDueDate(dateStr?: string) {
if (!dateStr) return null
try {
const date = new Date(dateStr)
const now = new Date()
const diffDays = Math.ceil((date.getTime() - now.getTime()) / (1000 * 60 * 60 * 24))
if (diffDays < 0) return { text: 'Overdue', class: 'overdue' }
if (diffDays === 0) return { text: 'Today', class: 'today' }
if (diffDays === 1) return { text: 'Tomorrow', class: 'soon' }
if (diffDays <= 7) return { text: `${diffDays}d`, class: 'soon' }
return { text: date.toLocaleDateString(), class: '' }
} catch {
return null
}
}
function goToProject(projectId: string) {
workspaceStore.setActiveProject(projectId)
router.push({ name: 'project', params: { id: projectId } })
}
function goToProjectTasks(projectId: string) {
workspaceStore.setActiveProject(projectId)
router.push({ name: 'project-tasks', params: { id: projectId } })
}
function goToTask(projectId: string, task: Task) {
workspaceStore.setActiveProject(projectId)
router.push({ name: 'project-tasks', params: { id: projectId, taskId: task.id } })
}
async function createProject() {
const name = prompt('Project name:')
if (!name) return
try {
const project = await projectsStore.createProject(name)
await workspaceStore.setActiveProject(project.id)
router.push({ name: 'project', params: { id: project.id } })
} catch {
// Error handled in store
}
}
onMounted(async () => {
try {
await Promise.all([
projectsStore.loadProjects(),
tasksStore.loadAllTasks()
])
} finally {
loading.value = false
}
})
</script>
<template>
<div class="dashboard">
<div class="dashboard-header">
<div class="header-left">
<h2>Dashboard</h2>
<span class="project-count">{{ projectSummaries.length }} projects</span>
</div>
<button class="primary" @click="createProject">+ New Project</button>
</div>
<div v-if="loading" class="loading">Loading...</div>
<div v-else-if="projectSummaries.length === 0" class="empty-state">
<h3>Welcome to Ironpad</h3>
<p>Create your first project to get started.</p>
<button class="primary" @click="createProject" style="margin-top: 16px">Create Project</button>
</div>
<div v-else class="dashboard-grid">
<div
v-for="project in projectSummaries"
:key="project.id"
class="project-card"
>
<!-- Card Header -->
<div class="card-header" @click="goToProject(project.id)">
<h3 class="card-title">{{ project.name }}</h3>
<div class="card-stats">
<span class="stat active-stat" :title="`${project.activeTasks.length} active`">
{{ project.activeTasks.length }} active
</span>
<span v-if="project.backlogCount > 0" class="stat backlog-stat" :title="`${project.backlogCount} backlog`">
{{ project.backlogCount }} backlog
</span>
<span v-if="project.overdueCount > 0" class="stat overdue-stat" :title="`${project.overdueCount} overdue`">
{{ project.overdueCount }} overdue
</span>
</div>
</div>
<!-- Active Tasks List -->
<div class="card-tasks" v-if="project.activeTasks.length > 0">
<div
v-for="task in project.activeTasks.slice(0, 5)"
:key="task.id"
class="card-task-item"
@click="goToTask(project.id, task)"
>
<span class="task-checkbox">&#9744;</span>
<span class="task-title">{{ task.title }}</span>
<div class="task-meta">
<span
v-for="tag in task.tags?.slice(0, 2)"
:key="tag"
class="task-tag"
>{{ tag }}</span>
<span
v-if="task.due_date && formatDueDate(task.due_date)"
:class="['task-due', formatDueDate(task.due_date)?.class]"
>{{ formatDueDate(task.due_date)?.text }}</span>
</div>
</div>
<div
v-if="project.activeTasks.length > 5"
class="card-task-more"
@click="goToProjectTasks(project.id)"
>
+{{ project.activeTasks.length - 5 }} more tasks...
</div>
</div>
<div v-else class="card-empty">
No active tasks
</div>
<!-- Card Footer -->
<div class="card-footer">
<span class="completed-count" v-if="project.completedCount > 0">
{{ project.completedCount }} completed
</span>
<button class="link-btn" @click="goToProjectTasks(project.id)">View All Tasks</button>
</div>
</div>
</div>
</div>
</template>
<style scoped>
.dashboard {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
}
.dashboard-header {
height: var(--header-height);
min-height: var(--header-height);
padding: 0 24px;
border-bottom: 1px solid var(--color-border);
display: flex;
justify-content: space-between;
align-items: center;
flex-shrink: 0;
}
.header-left {
display: flex;
align-items: baseline;
gap: 12px;
}
.dashboard-header h2 {
font-size: 16px;
font-weight: 600;
margin: 0;
}
.project-count {
font-size: 12px;
color: var(--color-text-secondary);
}
.dashboard-grid {
flex: 1;
overflow-y: auto;
padding: 24px;
display: grid;
grid-template-columns: repeat(auto-fill, minmax(340px, 1fr));
gap: 20px;
align-content: start;
}
/* Project Card */
.project-card {
background: var(--color-bg-secondary);
border: 1px solid var(--color-border);
border-radius: 10px;
overflow: hidden;
display: flex;
flex-direction: column;
transition: border-color 0.15s, box-shadow 0.15s;
}
.project-card:hover {
border-color: var(--color-primary);
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.08);
}
.card-header {
padding: 16px 20px 12px;
cursor: pointer;
}
.card-title {
font-size: 16px;
font-weight: 600;
margin: 0 0 8px 0;
}
.card-stats {
display: flex;
gap: 12px;
flex-wrap: wrap;
}
.stat {
font-size: 12px;
font-weight: 500;
padding: 2px 8px;
border-radius: 10px;
}
.active-stat {
background: rgba(88, 166, 255, 0.15);
color: var(--color-primary);
}
.backlog-stat {
background: rgba(153, 153, 153, 0.15);
color: var(--color-text-secondary);
}
.overdue-stat {
background: rgba(248, 81, 73, 0.15);
color: var(--color-danger);
}
/* Tasks in card */
.card-tasks {
padding: 0 12px;
flex: 1;
}
.card-task-item {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 8px;
border-radius: 6px;
cursor: pointer;
transition: background 0.12s;
}
.card-task-item:hover {
background: var(--color-bg-hover);
}
.card-task-item .task-checkbox {
flex-shrink: 0;
color: var(--color-text-secondary);
font-size: 14px;
}
.card-task-item .task-title {
flex: 1;
font-size: 13px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
min-width: 0;
}
.task-meta {
display: flex;
gap: 4px;
align-items: center;
flex-shrink: 0;
}
.task-tag {
font-size: 10px;
padding: 1px 6px;
border-radius: 8px;
background: var(--color-border);
color: var(--color-text-secondary);
white-space: nowrap;
}
.task-due {
font-size: 11px;
white-space: nowrap;
color: var(--color-text-secondary);
}
.task-due.overdue {
color: var(--color-danger);
font-weight: 500;
}
.task-due.today {
color: var(--color-danger);
}
.task-due.soon {
color: var(--color-primary);
}
.card-task-more {
padding: 8px 8px;
font-size: 12px;
color: var(--color-text-secondary);
cursor: pointer;
}
.card-task-more:hover {
color: var(--color-primary);
}
.card-empty {
padding: 16px 20px;
font-size: 13px;
color: var(--color-text-secondary);
font-style: italic;
flex: 1;
}
/* Card Footer */
.card-footer {
padding: 10px 20px;
border-top: 1px solid var(--color-border);
display: flex;
justify-content: space-between;
align-items: center;
}
.completed-count {
font-size: 12px;
color: var(--color-text-secondary);
}
.link-btn {
padding: 4px 8px;
border: none;
background: transparent;
color: var(--color-primary);
font-size: 12px;
cursor: pointer;
font-weight: 500;
}
.link-btn:hover {
text-decoration: underline;
}
/* States */
.loading,
.empty-state {
flex: 1;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
color: var(--color-text-secondary);
padding: 32px;
}
.empty-state h3 {
margin-bottom: 8px;
color: var(--color-text);
font-size: 20px;
}
</style>

View File

@@ -0,0 +1,358 @@
<script setup lang="ts">
import { ref, watch, computed } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useNotesStore, useWebSocketStore, useGitStore } from '../stores'
import MilkdownEditor from '../components/MilkdownEditor.vue'
const props = defineProps<{
id?: string
}>()
const route = useRoute()
const router = useRouter()
const notesStore = useNotesStore()
const wsStore = useWebSocketStore()
const gitStore = useGitStore()
const editorContent = ref('')
let saveTimeout: number | null = null
// CRITICAL: Separate key for editor recreation - only update AFTER content is ready
const editorKey = ref<string | null>(null)
// Track the last saved/loaded content to detect actual user changes
// This prevents unnecessary saves when just opening a note
const lastSavedContent = ref<string | null>(null)
// Track which note ID the pending save is for
let pendingSaveNoteId: string | null = null
const noteId = computed(() => props.id || (route.params.id as string))
const selectedNote = computed(() => notesStore.getNoteById(noteId.value))
const isReadOnly = computed(() => {
if (!notesStore.currentNote) return false
return wsStore.isFileLockedByOther(notesStore.currentNote.path)
})
function clearPendingSave() {
if (saveTimeout) {
clearTimeout(saveTimeout)
saveTimeout = null
}
pendingSaveNoteId = null
}
// Save current content immediately before switching notes
async function saveBeforeSwitch() {
const noteIdToSave = pendingSaveNoteId
const contentToSave = editorContent.value
const currentNoteId = notesStore.currentNote?.id
console.log('[NotesView] saveBeforeSwitch called:', {
noteIdToSave,
currentNoteId,
contentLength: contentToSave?.length,
hasCurrentNote: !!notesStore.currentNote
})
// Clear pending state first
clearPendingSave()
// Only save if we had a pending save for the current note
if (!noteIdToSave || !notesStore.currentNote) {
console.log('[NotesView] Skipping save - no pending save or no current note')
return
}
if (notesStore.currentNote.id !== noteIdToSave) {
console.log('[NotesView] Skipping save - note ID mismatch:', { currentNoteId: notesStore.currentNote.id, noteIdToSave })
return
}
// Only save if content actually changed
if (contentToSave === lastSavedContent.value) {
console.log('[NotesView] Skipping save before switch - content unchanged')
return
}
console.log('[NotesView] Saving content before switch:', { noteIdToSave, contentLength: contentToSave.length })
try {
await notesStore.saveNote(contentToSave)
lastSavedContent.value = contentToSave
console.log('[NotesView] Save completed successfully')
} catch (err) {
console.error('[NotesView] Save failed:', err)
}
}
// Auto-save with debounce
function scheduleAutoSave() {
console.log('[NotesView] scheduleAutoSave called for note:', noteId.value)
clearPendingSave()
notesStore.saveStatus = 'idle'
// Capture the current note ID for this save operation
pendingSaveNoteId = noteId.value || null
saveTimeout = window.setTimeout(async () => {
const noteIdToSave = pendingSaveNoteId
const contentToSave = editorContent.value
// Clear pending state
pendingSaveNoteId = null
saveTimeout = null
// Verify we're still on the same note - critical check to prevent overwrites
if (!noteIdToSave || !notesStore.currentNote || noteId.value !== noteIdToSave) {
console.log('[NotesView] Skipping save - note changed:', { noteIdToSave, currentNoteId: noteId.value })
return
}
// Double-check the current note ID matches
if (notesStore.currentNote.id !== noteIdToSave) {
console.log('[NotesView] Skipping save - currentNote mismatch:', { noteIdToSave, currentNoteId: notesStore.currentNote.id })
return
}
// Final check: only save if content actually changed from last saved
if (contentToSave === lastSavedContent.value) {
console.log('[NotesView] Skipping save - content unchanged from last save')
return
}
try {
await notesStore.saveNote(contentToSave)
// Update last saved content on success
lastSavedContent.value = contentToSave
gitStore.loadStatus()
} catch {
// Error handled in store
}
}, 1000)
}
async function saveNote() {
clearPendingSave()
if (!notesStore.currentNote) return
try {
await notesStore.saveNote(editorContent.value)
// Update last saved content on success
lastSavedContent.value = editorContent.value
gitStore.loadStatus()
} catch {
// Error handled in store
}
}
async function deleteNote() {
if (!confirm('Archive this note?')) return
try {
await notesStore.deleteNote()
router.push({ name: 'home' })
} catch {
// Error handled in store
}
}
// Watch for note changes
watch(noteId, async (newId, oldId) => {
console.log('[NotesView] noteId changed:', { oldId, newId, pendingSaveNoteId })
// Save any pending content from the previous note BEFORE switching
if (oldId && pendingSaveNoteId) {
console.log('[NotesView] Has pending save, calling saveBeforeSwitch')
await saveBeforeSwitch()
} else {
console.log('[NotesView] No pending save, clearing')
clearPendingSave()
}
notesStore.saveStatus = 'idle'
if (newId) {
console.log('[NotesView] Loading note:', newId)
await notesStore.loadNote(newId)
// CRITICAL: Set content BEFORE updating editorKey
const loadedContent = notesStore.currentNote?.content ?? ''
console.log('[NotesView] Setting editor content, length:', loadedContent.length)
editorContent.value = loadedContent
// Track this as the "original" content - only save if user makes changes
lastSavedContent.value = loadedContent
// NOW update the editor key - this triggers editor recreation with correct content
editorKey.value = newId
console.log('[NotesView] Updated editorKey to:', newId)
} else {
notesStore.clearCurrentNote()
editorContent.value = ''
lastSavedContent.value = null
editorKey.value = null
}
}, { immediate: true })
// Watch for content changes - ONLY save when content differs from last saved
watch(editorContent, (newContent) => {
// Skip if no note loaded or read-only
if (!notesStore.currentNote || isReadOnly.value) {
return
}
// CRITICAL: Only schedule auto-save if content actually differs from last saved/loaded
// This prevents unnecessary saves when just opening a note
if (lastSavedContent.value !== null && newContent !== lastSavedContent.value) {
console.log('[NotesView] Content changed from last saved, scheduling auto-save')
scheduleAutoSave()
}
})
// Milkdown is WYSIWYG - no separate preview needed
</script>
<template>
<div class="notes-view">
<template v-if="notesStore.currentNote && editorKey">
<div class="view-header">
<h2>{{ selectedNote?.title ?? notesStore.currentNote.id }}</h2>
<div class="button-group">
<span :class="['status', notesStore.saveStatus]">
<template v-if="notesStore.saveStatus === 'saving'">Saving...</template>
<template v-else-if="notesStore.saveStatus === 'saved'">Saved</template>
<template v-else-if="notesStore.saveStatus === 'error'">Save failed</template>
</span>
<span v-if="wsStore.connected" class="ws-status connected" title="Real-time sync active"></span>
<span v-else class="ws-status" title="Connecting..."></span>
<button @click="saveNote" :disabled="notesStore.saveStatus === 'saving'">Save</button>
<button class="danger" @click="deleteNote">Archive</button>
</div>
</div>
<div v-if="isReadOnly" class="read-only-banner">
🔒 This file is being edited elsewhere. Read-only mode.
</div>
<div class="view-content">
<div v-if="notesStore.loadingNote" class="loading">Loading note...</div>
<div v-else class="editor-container">
<MilkdownEditor
v-model="editorContent"
:editor-key="editorKey"
:readonly="isReadOnly"
placeholder="Start writing..."
/>
</div>
</div>
</template>
<div v-else class="empty-state">
<h2>No note selected</h2>
<p>Select a note from the sidebar or create a new one.</p>
<p class="shortcuts">
<kbd>Ctrl+K</kbd> Search · <kbd>Ctrl+S</kbd> Save
</p>
<button class="primary" @click="notesStore.createNote().then(n => router.push({ name: 'note', params: { id: n.id } }))" style="margin-top: 16px">
Create Note
</button>
</div>
</div>
</template>
<style scoped>
.notes-view {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
}
.view-header {
height: var(--header-height);
padding: 0 16px;
border-bottom: 1px solid var(--color-border);
display: flex;
justify-content: space-between;
align-items: center;
}
.view-header h2 {
font-size: 14px;
font-weight: 500;
margin: 0;
}
.button-group {
display: flex;
gap: 8px;
align-items: center;
}
.status {
font-size: 12px;
color: var(--color-text-secondary);
}
.status.saving { color: var(--color-primary); }
.status.saved { color: var(--color-success); }
.status.error { color: var(--color-danger); }
.ws-status {
font-size: 10px;
color: var(--color-text-secondary);
}
.ws-status.connected {
color: var(--color-success);
}
.read-only-banner {
padding: 8px 16px;
background: var(--color-primary);
color: white;
font-size: 13px;
}
.view-content {
flex: 1;
overflow: hidden;
display: flex;
}
.editor-container {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
}
.empty-state {
flex: 1;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
color: var(--color-text-secondary);
padding: 32px;
}
.empty-state h2 {
margin-bottom: 8px;
font-weight: 500;
}
.shortcuts {
margin-top: 16px;
font-size: 12px;
}
.shortcuts kbd {
background: var(--color-border);
padding: 2px 6px;
border-radius: 4px;
font-family: monospace;
}
.loading {
padding: 16px;
color: var(--color-text-secondary);
text-align: center;
}
</style>

View File

@@ -0,0 +1,529 @@
<script setup lang="ts">
import { ref, computed, watch, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useWorkspaceStore, useGitStore } from '../stores'
import { projectsApi } from '../api/client'
import type { ProjectNote, ProjectNoteWithContent } from '../types'
import MilkdownEditor from '../components/MilkdownEditor.vue'
const props = defineProps<{
id: string
noteId?: string
}>()
const route = useRoute()
const router = useRouter()
const workspaceStore = useWorkspaceStore()
const gitStore = useGitStore()
const projectId = computed(() => props.id || (route.params.id as string))
const currentNoteId = computed(() => props.noteId || (route.params.noteId as string | undefined))
// Notes list state
const notes = ref<ProjectNote[]>([])
const loading = ref(false)
const error = ref<string | null>(null)
// Editor state
const selectedNote = ref<ProjectNoteWithContent | null>(null)
const editorContent = ref('')
const editorLoading = ref(false)
const saveStatus = ref<'idle' | 'saving' | 'saved' | 'error'>('idle')
let saveTimeout: number | null = null
// CRITICAL: Separate key for editor recreation - only update AFTER content is ready
// This prevents the race condition where the editor recreates before content loads
const editorKey = ref<string | null>(null)
// Track the last saved/loaded content to detect actual user changes
// This prevents unnecessary saves when just opening a note
const lastSavedContent = ref<string | null>(null)
// Track which note ID the pending save is for
let pendingSaveNoteId: string | null = null
async function loadNotes() {
loading.value = true
error.value = null
try {
notes.value = await projectsApi.listNotes(projectId.value)
} catch (err) {
error.value = `Failed to load notes: ${err}`
} finally {
loading.value = false
}
}
async function loadNote(noteId: string) {
editorLoading.value = true
try {
selectedNote.value = await projectsApi.getNote(projectId.value, noteId)
// CRITICAL: Set content BEFORE updating editorKey
// This ensures when the editor recreates, it has the correct defaultValue
const newContent = selectedNote.value?.content ?? ''
editorContent.value = newContent
console.log('[ProjectNotesView] Loaded note content, length:', newContent.length)
// Track this as the "original" content - only save if user makes changes
lastSavedContent.value = newContent
// NOW update the editor key - this triggers editor recreation with correct content
editorKey.value = noteId
console.log('[ProjectNotesView] Updated editorKey to:', noteId)
} catch {
selectedNote.value = null
editorContent.value = ''
lastSavedContent.value = null
editorKey.value = null
} finally {
editorLoading.value = false
}
}
async function createNote() {
const title = prompt('Note title (optional):')
try {
const note = await projectsApi.createNote(projectId.value, title || undefined)
await loadNotes() // Refresh list
// Select the new note
const filename = note.path.split('/').pop()?.replace('.md', '')
if (filename) {
router.push({ name: 'project-notes', params: { id: projectId.value, noteId: filename } })
}
} catch (err) {
error.value = `Failed to create note: ${err}`
}
}
function selectNote(note: ProjectNote) {
const filename = note.path.split('/').pop()?.replace('.md', '')
if (filename) {
router.push({ name: 'project-notes', params: { id: projectId.value, noteId: filename } })
}
}
function clearPendingSave() {
if (saveTimeout) {
clearTimeout(saveTimeout)
saveTimeout = null
}
pendingSaveNoteId = null
}
// Save current content immediately before switching notes
async function saveBeforeSwitch() {
const noteIdToSave = pendingSaveNoteId
const contentToSave = editorContent.value
console.log('[ProjectNotesView] saveBeforeSwitch called:', {
noteIdToSave,
hasSelectedNote: !!selectedNote.value,
contentLength: contentToSave?.length
})
// Clear pending state first
clearPendingSave()
// Only save if we had a pending save for the current note
if (!noteIdToSave || !selectedNote.value) {
console.log('[ProjectNotesView] Skipping save - no pending save or no selected note')
return
}
// Only save if content actually changed
if (contentToSave === lastSavedContent.value) {
console.log('[ProjectNotesView] Skipping save before switch - content unchanged')
return
}
console.log('[ProjectNotesView] Saving content before switch:', { noteIdToSave, contentLength: contentToSave.length })
try {
await projectsApi.updateNote(projectId.value, noteIdToSave, contentToSave)
lastSavedContent.value = contentToSave
console.log('[ProjectNotesView] Save completed successfully')
gitStore.loadStatus()
} catch (err) {
console.error('[ProjectNotesView] Save failed:', err)
}
}
function scheduleAutoSave() {
console.log('[ProjectNotesView] scheduleAutoSave called for note:', currentNoteId.value)
clearPendingSave()
saveStatus.value = 'idle'
// Capture the current note ID for this save operation
pendingSaveNoteId = currentNoteId.value || null
saveTimeout = window.setTimeout(saveNoteContent, 1000)
}
async function saveNoteContent() {
const noteIdToSave = pendingSaveNoteId
const contentToSave = editorContent.value
// Clear pending state
pendingSaveNoteId = null
saveTimeout = null
// Verify we're still on the same note - critical check to prevent overwrites
if (!noteIdToSave || !selectedNote.value || currentNoteId.value !== noteIdToSave) {
console.log('[ProjectNotesView] Skipping save - note changed:', { noteIdToSave, currentNoteId: currentNoteId.value })
return
}
// Final check: only save if content actually changed from last saved
if (contentToSave === lastSavedContent.value) {
console.log('[ProjectNotesView] Skipping save - content unchanged from last save')
return
}
try {
saveStatus.value = 'saving'
const savedNote = await projectsApi.updateNote(projectId.value, noteIdToSave, contentToSave)
// Only update state if we're still on the same note
if (currentNoteId.value === noteIdToSave) {
selectedNote.value = savedNote
lastSavedContent.value = contentToSave
saveStatus.value = 'saved'
setTimeout(() => {
if (saveStatus.value === 'saved') saveStatus.value = 'idle'
}, 2000)
}
gitStore.loadStatus()
await loadNotes() // Refresh list to update timestamps
} catch (err) {
if (currentNoteId.value === noteIdToSave) {
saveStatus.value = 'error'
}
}
}
async function saveNote() {
clearPendingSave()
if (!selectedNote.value || !currentNoteId.value) return
try {
saveStatus.value = 'saving'
selectedNote.value = await projectsApi.updateNote(projectId.value, currentNoteId.value, editorContent.value)
lastSavedContent.value = editorContent.value
saveStatus.value = 'saved'
gitStore.loadStatus()
await loadNotes() // Refresh list to update timestamps
setTimeout(() => {
if (saveStatus.value === 'saved') saveStatus.value = 'idle'
}, 2000)
} catch (err) {
saveStatus.value = 'error'
}
}
async function deleteNote() {
if (!selectedNote.value || !currentNoteId.value) return
if (!confirm('Are you sure you want to delete this note?')) return
try {
await projectsApi.deleteNote(projectId.value, currentNoteId.value)
selectedNote.value = null
editorContent.value = ''
router.push({ name: 'project-notes', params: { id: projectId.value } })
await loadNotes()
} catch (err) {
alert(`Failed to delete note: ${err}`)
}
}
function formatDate(dateStr: string) {
if (!dateStr) return ''
try {
return new Date(dateStr).toLocaleDateString()
} catch {
return dateStr
}
}
function isSelected(note: ProjectNote) {
const filename = note.path.split('/').pop()?.replace('.md', '')
return filename === currentNoteId.value
}
// Watch for content changes - ONLY save when content differs from last saved
watch(editorContent, (newContent) => {
// Skip if no note loaded
if (!selectedNote.value) {
return
}
// CRITICAL: Only schedule auto-save if content actually differs from last saved/loaded
// This prevents unnecessary saves when just opening a note
if (lastSavedContent.value !== null && newContent !== lastSavedContent.value) {
console.log('[ProjectNotesView] Content changed from last saved, scheduling auto-save')
scheduleAutoSave()
}
})
watch(projectId, () => {
// Clear any pending saves when switching projects
clearPendingSave()
editorContent.value = ''
lastSavedContent.value = null
selectedNote.value = null
loadNotes()
}, { immediate: true })
watch(currentNoteId, async (noteId, oldNoteId) => {
console.log('[ProjectNotesView] currentNoteId changed:', { oldNoteId, noteId, pendingSaveNoteId })
// Save any pending content from the previous note BEFORE switching
if (oldNoteId && pendingSaveNoteId) {
console.log('[ProjectNotesView] Has pending save, calling saveBeforeSwitch')
await saveBeforeSwitch()
} else {
clearPendingSave()
}
saveStatus.value = 'idle'
if (noteId) {
console.log('[ProjectNotesView] Loading note:', noteId)
await loadNote(noteId)
} else {
selectedNote.value = null
editorContent.value = ''
lastSavedContent.value = null
editorKey.value = null
}
}, { immediate: true })
onMounted(() => {
if (workspaceStore.activeProjectId !== projectId.value) {
workspaceStore.setActiveProject(projectId.value)
}
})
</script>
<template>
<div class="notes-split-view">
<!-- Notes List Panel -->
<div class="notes-list-panel">
<div class="panel-header">
<h3>Notes</h3>
<button class="primary small" @click="createNote">+ New</button>
</div>
<div v-if="loading" class="loading-small">Loading...</div>
<div v-else-if="notes.length === 0" class="empty-list">
<p>No notes yet</p>
</div>
<div v-else class="notes-list">
<div
v-for="note in notes"
:key="note.id"
:class="['note-item', { selected: isSelected(note) }]"
@click="selectNote(note)"
>
<div class="note-title">{{ note.title || 'Untitled' }}</div>
<div class="note-meta">{{ formatDate(note.updated) }}</div>
</div>
</div>
</div>
<!-- Editor Panel -->
<div class="editor-panel">
<template v-if="currentNoteId && selectedNote && editorKey">
<div class="editor-header">
<h3>{{ selectedNote.title || 'Untitled' }}</h3>
<div class="editor-actions">
<span :class="['status', saveStatus]">
<template v-if="saveStatus === 'saving'">Saving...</template>
<template v-else-if="saveStatus === 'saved'">Saved</template>
<template v-else-if="saveStatus === 'error'">Error</template>
</span>
<button @click="saveNote" :disabled="saveStatus === 'saving'">Save</button>
<button class="danger" @click="deleteNote">Delete</button>
</div>
</div>
<div class="editor-content">
<MilkdownEditor
v-model="editorContent"
:editor-key="editorKey"
:project-id="projectId"
placeholder="Write your note..."
/>
</div>
</template>
<template v-else-if="editorLoading">
<div class="editor-placeholder">
<p>Loading note...</p>
</div>
</template>
<template v-else>
<div class="editor-placeholder">
<h3>Select a note</h3>
<p>Choose a note from the list or create a new one.</p>
<button class="primary" @click="createNote">+ New Note</button>
</div>
</template>
</div>
</div>
</template>
<style scoped>
.notes-split-view {
flex: 1;
display: flex;
overflow: hidden;
min-width: 0;
}
/* Notes List Panel */
.notes-list-panel {
width: 280px;
flex-shrink: 0;
border-right: 1px solid var(--color-border);
display: flex;
flex-direction: column;
background: var(--color-bg-secondary);
overflow: hidden;
}
.panel-header {
height: 52px;
min-height: 52px;
padding: 0 16px;
border-bottom: 1px solid var(--color-border);
display: flex;
justify-content: space-between;
align-items: center;
flex-shrink: 0;
}
.panel-header h3 {
font-size: 13px;
font-weight: 600;
margin: 0;
}
button.small {
padding: 4px 10px;
font-size: 12px;
}
.notes-list {
flex: 1;
overflow-y: auto;
overflow-x: hidden;
}
.note-item {
padding: 12px 16px;
border-bottom: 1px solid var(--color-border);
cursor: pointer;
transition: background 0.15s;
}
.note-item:hover {
background: var(--color-border);
}
.note-item.selected {
background: var(--color-primary);
color: white;
}
.note-item.selected .note-meta {
color: rgba(255, 255, 255, 0.7);
}
.note-title {
font-weight: 500;
font-size: 13px;
margin-bottom: 2px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.note-meta {
font-size: 11px;
color: var(--color-text-secondary);
}
.loading-small,
.empty-list {
padding: 24px 16px;
text-align: center;
color: var(--color-text-secondary);
font-size: 13px;
}
/* Editor Panel */
.editor-panel {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
min-width: 0;
}
.editor-header {
height: 52px;
min-height: 52px;
padding: 0 16px;
border-bottom: 1px solid var(--color-border);
display: flex;
justify-content: space-between;
align-items: center;
flex-shrink: 0;
}
.editor-header h3 {
font-size: 14px;
font-weight: 500;
margin: 0;
}
.editor-actions {
display: flex;
gap: 8px;
align-items: center;
}
.status {
font-size: 12px;
color: var(--color-text-secondary);
}
.status.saving { color: var(--color-primary); }
.status.saved { color: var(--color-success); }
.status.error { color: var(--color-danger); }
.editor-content {
flex: 1;
overflow: hidden;
display: flex;
}
.editor-placeholder {
flex: 1;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
color: var(--color-text-secondary);
padding: 32px;
}
.editor-placeholder h3 {
margin-bottom: 8px;
color: var(--color-text);
}
.editor-placeholder p {
margin-bottom: 16px;
}
</style>

View File

@@ -0,0 +1,188 @@
<script setup lang="ts">
import { ref, computed, watch, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useProjectsStore, useGitStore, useWorkspaceStore } from '../stores'
import { projectsApi } from '../api/client'
import type { ProjectWithContent } from '../types'
import MilkdownEditor from '../components/MilkdownEditor.vue'
const props = defineProps<{
id: string
}>()
const route = useRoute()
const router = useRouter()
const projectsStore = useProjectsStore()
const gitStore = useGitStore()
const workspaceStore = useWorkspaceStore()
const projectId = computed(() => props.id || (route.params.id as string))
const project = computed(() => projectsStore.getProjectById(projectId.value))
const projectContent = ref<ProjectWithContent | null>(null)
const editorContent = ref('')
const loading = ref(false)
const saveStatus = ref<'idle' | 'saving' | 'saved' | 'error'>('idle')
let saveTimeout: number | null = null
function goToTasks() {
router.push({ name: 'project-tasks', params: { id: projectId.value } })
}
function scheduleAutoSave() {
if (saveTimeout) clearTimeout(saveTimeout)
saveStatus.value = 'idle'
saveTimeout = window.setTimeout(saveNote, 1000)
}
async function saveNote() {
if (!projectContent.value) return
try {
saveStatus.value = 'saving'
projectContent.value = await projectsApi.updateContent(projectId.value, editorContent.value)
saveStatus.value = 'saved'
gitStore.loadStatus()
setTimeout(() => {
if (saveStatus.value === 'saved') saveStatus.value = 'idle'
}, 2000)
} catch (err) {
saveStatus.value = 'error'
}
}
async function loadProjectContent() {
loading.value = true
try {
projectContent.value = await projectsApi.getContent(projectId.value)
editorContent.value = projectContent.value?.content ?? ''
} catch {
projectContent.value = null
} finally {
loading.value = false
}
}
watch(editorContent, (newContent, oldContent) => {
if (projectContent.value && oldContent !== undefined && newContent !== oldContent) {
scheduleAutoSave()
}
})
watch(projectId, () => {
loadProjectContent()
}, { immediate: true })
onMounted(async () => {
await projectsStore.loadProject(projectId.value)
// Set as active project
if (workspaceStore.activeProjectId !== projectId.value) {
await workspaceStore.setActiveProject(projectId.value)
}
})
</script>
<template>
<div class="project-view">
<div class="view-header">
<h2>{{ project?.name ?? projectId }}</h2>
<div class="button-group">
<span :class="['status', saveStatus]">
<template v-if="saveStatus === 'saving'">Saving...</template>
<template v-else-if="saveStatus === 'saved'">Saved</template>
<template v-else-if="saveStatus === 'error'">Save failed</template>
</span>
<button class="primary" @click="goToTasks">View Tasks</button>
<button @click="saveNote" :disabled="saveStatus === 'saving'">Save</button>
</div>
</div>
<div v-if="loading" class="loading">Loading project...</div>
<div v-else-if="projectContent" class="view-content">
<div class="editor-container">
<MilkdownEditor
v-model="editorContent"
:project-id="projectId"
placeholder="Write about this project..."
/>
</div>
</div>
<div v-else class="empty-state">
<h3>Project not found</h3>
<p>This project doesn't exist or couldn't be loaded.</p>
</div>
</div>
</template>
<style scoped>
.project-view {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
}
.view-header {
height: var(--header-height);
min-height: var(--header-height);
max-height: var(--header-height);
padding: 0 16px;
border-bottom: 1px solid var(--color-border);
display: flex;
justify-content: space-between;
align-items: center;
flex-shrink: 0;
}
.view-header h2 {
font-size: 14px;
font-weight: 500;
margin: 0;
}
.button-group {
display: flex;
gap: 8px;
align-items: center;
}
.status {
font-size: 12px;
color: var(--color-text-secondary);
}
.status.saving { color: var(--color-primary); }
.status.saved { color: var(--color-success); }
.status.error { color: var(--color-danger); }
.view-content {
flex: 1;
overflow: hidden;
display: flex;
}
.editor-container {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
}
.loading,
.empty-state {
flex: 1;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
color: var(--color-text-secondary);
padding: 32px;
}
.empty-state h3 {
margin-bottom: 8px;
}
</style>

View File

@@ -0,0 +1,132 @@
<script setup lang="ts">
import { onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { useProjectsStore } from '../stores'
const router = useRouter()
const projectsStore = useProjectsStore()
async function createProject() {
const name = prompt('Project name:')
if (!name) return
try {
const project = await projectsStore.createProject(name)
router.push({ name: 'project', params: { id: project.id } })
} catch {
// Error handled in store
}
}
function selectProject(id: string) {
router.push({ name: 'project', params: { id } })
}
onMounted(() => {
projectsStore.loadProjects()
})
</script>
<template>
<div class="projects-view">
<div class="view-header">
<h2>Projects</h2>
<button class="primary" @click="createProject">+ New Project</button>
</div>
<div v-if="projectsStore.loading" class="loading">Loading projects...</div>
<div v-else-if="projectsStore.projects.length === 0" class="empty-state">
<h3>No projects yet</h3>
<p>Create your first project to get started.</p>
<button class="primary" @click="createProject" style="margin-top: 16px">Create Project</button>
</div>
<div v-else class="projects-grid">
<div
v-for="project in projectsStore.sortedProjects"
:key="project.id"
class="project-card"
@click="selectProject(project.id)"
>
<h3>{{ project.name }}</h3>
<p class="project-path">{{ project.path }}</p>
</div>
</div>
</div>
</template>
<style scoped>
.projects-view {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
}
.view-header {
height: var(--header-height);
padding: 0 16px;
border-bottom: 1px solid var(--color-border);
display: flex;
justify-content: space-between;
align-items: center;
}
.view-header h2 {
font-size: 14px;
font-weight: 500;
margin: 0;
}
.projects-grid {
flex: 1;
overflow-y: auto;
padding: 24px;
display: grid;
grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
gap: 16px;
align-content: start;
}
.project-card {
padding: 20px;
border-radius: 8px;
background: var(--color-bg-secondary);
border: 1px solid var(--color-border);
cursor: pointer;
transition: border-color 0.15s, box-shadow 0.15s;
}
.project-card:hover {
border-color: var(--color-primary);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
.project-card h3 {
margin: 0 0 8px 0;
font-size: 16px;
font-weight: 600;
}
.project-path {
margin: 0;
font-size: 12px;
color: var(--color-text-secondary);
}
.loading,
.empty-state {
flex: 1;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
color: var(--color-text-secondary);
padding: 32px;
}
.empty-state h3 {
margin-bottom: 8px;
}
</style>

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,16 @@
{
"extends": "@vue/tsconfig/tsconfig.dom.json",
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"types": ["vite/client"],
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue"]
}

7
frontend/tsconfig.json Normal file
View File

@@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}

View File

@@ -0,0 +1,26 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "ES2023",
"lib": ["ES2023"],
"module": "ESNext",
"types": ["node"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["vite.config.ts"]
}

19
frontend/vite.config.ts Normal file
View File

@@ -0,0 +1,19 @@
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
// https://vite.dev/config/
export default defineConfig({
plugins: [vue()],
server: {
proxy: {
'/api': {
target: 'http://localhost:3000',
changeOrigin: true,
},
'/ws': {
target: 'ws://localhost:3000',
ws: true,
},
},
},
})