15 Commits
v0.1.0 ... v.1

Author SHA1 Message Date
5299190973 Add ssh skeleton directory
Some checks failed
CI / Backend (Rust) (push) Failing after 11s
CI / Frontend (Vue) (push) Successful in 10m2s
Release / Build Linux (x86_64) (push) Failing after 54s
Release / Build macOS (x86_64) (push) Has been cancelled
Release / Build Windows (x86_64) (push) Has been cancelled
Release / Create Release (push) Has been cancelled
2026-02-27 13:00:02 +00:00
Keith Solomon
f1f106c948 🐞 fix: Even more git fixes
Some checks failed
CI / Backend (Rust) (push) Failing after 9s
CI / Frontend (Vue) (push) Successful in 9m57s
2026-02-25 08:05:02 -06:00
Keith Solomon
2a55d5ebec 🐞 fix: More git fixes
Some checks failed
CI / Backend (Rust) (push) Failing after 10s
CI / Frontend (Vue) (push) Successful in 9m57s
2026-02-23 18:25:20 -06:00
Keith Solomon
adefed7c74 🐞 fix: Git fixes
Some checks failed
CI / Backend (Rust) (push) Failing after 9s
CI / Frontend (Vue) (push) Successful in 9m57s
2026-02-23 17:04:36 -06:00
Keith Solomon
d39bbd1801 feature: Add remote git support
Some checks failed
CI / Backend (Rust) (push) Failing after 9s
CI / Frontend (Vue) (push) Successful in 9m56s
2026-02-23 08:50:06 -06:00
Keith Solomon
0b5816621b 🐞 fix: Update #2 for dbus error
Some checks failed
CI / Backend (Rust) (push) Failing after 9s
CI / Frontend (Vue) (push) Successful in 9m54s
2026-02-23 08:22:32 -06:00
Keith Solomon
97508e4f0a 🐞 fix: Update for dbus error
Some checks failed
CI / Backend (Rust) (push) Failing after 9s
CI / Frontend (Vue) (push) Successful in 9m53s
2026-02-23 06:53:14 -06:00
Keith Solomon
d98181bed9 feature: Add docker support
Some checks failed
CI / Backend (Rust) (push) Failing after 9s
CI / Frontend (Vue) (push) Successful in 10m0s
2026-02-22 22:38:55 -06:00
skepsismusic
10ead43260 Update Cargo.lock for ksni dependency and add release to backend gitignore
Some checks failed
CI / Backend (Rust) (push) Failing after 1m36s
CI / Frontend (Vue) (push) Has been cancelled
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-16 14:21:36 +01:00
skepsismusic
9b9cb1f0b4 Fix cargo fmt formatting in projects.rs and tasks.rs
Some checks failed
Release / Build Linux (x86_64) (push) Has been cancelled
Release / Build macOS (x86_64) (push) Has been cancelled
Release / Build Windows (x86_64) (push) Has been cancelled
Release / Create Release (push) Has been cancelled
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-16 14:11:00 +01:00
skepsismusic
bbffc0e6bb Fix Linux build: enable ksni feature for tray-item and add libdbus-1-dev to CI
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-16 14:05:51 +01:00
skepsismusic
7c91e1775a Update graphics for v0.2.0: roadmap, architecture, and what's-new feature highlight
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-16 13:57:19 +01:00
skepsismusic
781ea28097 Release v0.2.0: Task comments, recurring calendar, system tray, app branding
New features:
- Task comments with date-stamped entries and last-comment summary
- Recurring tasks expanded on calendar (daily/weekly/monthly/yearly)
- System tray mode replacing CMD window (Windows/macOS/Linux)
- Ironpad logo as exe icon, tray icon, favicon, and header logo

Technical changes:
- Backend restructured for dual-mode: dev (API-only) / prod (tray + server)
- tray-item crate for cross-platform tray, winresource for icon embedding
- Calendar view refactored with CalendarEntry interface for recurring merging
- Added CHANGELOG.md, build-local.ps1, version bumped to 0.2.0

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-16 13:48:54 +01:00
skepsismusic
b150a243fd update roadmap and readme 2026-02-07 17:38:34 +01:00
skepsismusic
df27a27a2d updated roadmap 2026-02-06 15:05:42 +01:00
49 changed files with 2567 additions and 171 deletions

13
.dockerignore Normal file
View File

@@ -0,0 +1,13 @@
.git
.github
.ferrite
backend/target
frontend/node_modules
frontend/dist
data
docs
*.log
*.tmp

11
.ferrite/state.json Normal file
View File

@@ -0,0 +1,11 @@
{
"recent_files": [
"G:\\DEV\\proman2k\\README.md",
"G:\\DEV\\proman2k\\assets\\test.md"
],
"expanded_paths": [
"G:\\DEV\\proman2k"
],
"file_tree_width": 250.0,
"show_file_tree": true
}

View File

@@ -0,0 +1,20 @@
{
"name": "Workspace",
"tabs": [
{
"name": "Tab 1",
"layout": {
"Terminal": 1
},
"terminals": {
"1": {
"shell": "Default",
"cwd": "G:\\DEV\\proman2k",
"title": "C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe"
}
}
}
],
"floating_windows": [],
"active_tab_index": 0
}

View File

@@ -13,6 +13,10 @@ jobs:
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- name: Install system dependencies (Linux)
if: runner.os == 'Linux'
run: sudo apt-get update && sudo apt-get install -y libdbus-1-dev pkg-config
- name: Install Rust toolchain - name: Install Rust toolchain
uses: dtolnay/rust-toolchain@stable uses: dtolnay/rust-toolchain@stable

View File

@@ -43,7 +43,7 @@ jobs:
- name: Install system dependencies (Linux) - name: Install system dependencies (Linux)
if: runner.os == 'Linux' if: runner.os == 'Linux'
run: sudo apt-get update && sudo apt-get install -y cmake run: sudo apt-get update && sudo apt-get install -y cmake libdbus-1-dev pkg-config
- name: Install Node.js - name: Install Node.js
uses: actions/setup-node@v4 uses: actions/setup-node@v4

4
.gitignore vendored
View File

@@ -37,9 +37,13 @@ data/notes/assets/*
!data/notes/assets/.gitkeep !data/notes/assets/.gitkeep
data/projects/*/ data/projects/*/
!data/projects/.gitkeep !data/projects/.gitkeep
Codex Session.txt
# === Stray root lock file (frontend/package-lock.json is kept for CI) === # === Stray root lock file (frontend/package-lock.json is kept for CI) ===
/package-lock.json /package-lock.json
# === Local build output ===
release/
# === Generated images (article assets, not source) === # === Generated images (article assets, not source) ===
/assets/ /assets/

39
CHANGELOG.md Normal file
View File

@@ -0,0 +1,39 @@
# Changelog
All notable changes to Ironpad are documented here.
## [0.2.0] - 2026-02-16
### Added
- **Task comments** -- date-stamped comment entries per task, stored as YAML in frontmatter. Last comment shown as summary in task list and dashboard cards. Add/delete via API and UI.
- **Recurring tasks on calendar** -- tasks with daily/weekly/monthly/yearly recurrence now appear on the calendar even without an explicit `due_date`. Occurrences are computed from the anchor date (`due_date` or `created`) and `recurrence_interval`. Recurring entries show with a dashed border and recurrence icon to distinguish from regular due-date tasks.
- **System tray mode** -- production binary runs in the system tray instead of a console window. Tray menu with "Open in Browser" and "Quit". No CMD window on Windows in release builds (`windows_subsystem = "windows"`). Server runs on a background thread with the tray event loop on the main thread for cross-platform safety.
- **App icon and branding** -- Ironpad logo embedded in the Windows executable (Explorer icon + tray icon) via `winresource`. Favicon and logo added to the web UI (browser tab + top bar header).
- **Local build script** -- `build-local.ps1` for building a testable release package locally.
### Changed
- Backend `main.rs` restructured for dual-mode operation: development mode runs the server directly (no tray), production mode runs server on background thread with tray on main thread.
- Calendar view refactored to use `CalendarEntry` interface that merges regular due-date tasks with computed recurring occurrences.
### Dependencies
- Added `tray-item = "0.10"` for cross-platform system tray support.
- Added `windows-sys = "0.52"` (Windows only) for loading the embedded icon resource.
- Added `winresource = "0.1"` (Windows build dependency) for embedding the icon in the .exe.
---
## [0.1.0] - 2025-12-01
### Added
- Initial release of Ironpad -- local-first, file-based project and knowledge management.
- **Backend**: Rust/Axum API server with dynamic port (3000-3010), WebSocket real-time sync, file watcher, Git auto-commit (60s batching), ripgrep search.
- **Frontend**: Vue 3 SPA with Milkdown WYSIWYG editor, dark/light theme, Pinia state management.
- **File-based tasks**: each task stored as a markdown file with YAML frontmatter (title, completed, section, priority, due_date, tags, subtasks, recurrence).
- **Split-panel task view**: task list with active/backlog/completed sections, detail editor with markdown, due date picker, tag system, subtasks, recurrence picker.
- **Calendar view**: month grid showing tasks by due date with color-coded urgency and daily note indicators.
- **Dashboard**: cross-project home page with active task summaries per project.
- **Daily notes**: date-based notes with templates.
- **Git panel**: commit history with diffs, working directory changes, push/fetch with ahead/behind indicators.
- **Project notes**: split-panel notes view per project.
- **Search**: Ctrl+K search panel with ripgrep-powered full-text search.
- Cross-platform builds (Windows, macOS, Linux) via GitHub Actions.

41
Dockerfile Normal file
View File

@@ -0,0 +1,41 @@
FROM node:20-bookworm-slim AS frontend-builder
WORKDIR /src/frontend
COPY frontend/package.json frontend/package-lock.json ./
RUN npm ci
COPY frontend/ ./
RUN npm run build
FROM rust:1.85-bookworm AS backend-builder
WORKDIR /src/backend
RUN apt-get update \
&& apt-get install -y --no-install-recommends pkg-config libdbus-1-dev \
&& rm -rf /var/lib/apt/lists/*
COPY backend/ ./
RUN cargo build --release
FROM debian:bookworm-slim
WORKDIR /app
RUN apt-get update \
&& apt-get install -y --no-install-recommends ca-certificates ripgrep libdbus-1-3 git openssh-client \
&& rm -rf /var/lib/apt/lists/*
COPY --from=backend-builder /src/backend/target/release/ironpad /app/ironpad
COPY --from=frontend-builder /src/frontend/dist /app/static
RUN mkdir -p /app/data
ENV IRONPAD_HOST=0.0.0.0
ENV IRONPAD_PORT=3000
ENV IRONPAD_DISABLE_TRAY=1
ENV IRONPAD_DATA_DIR=/app/data
ENV RUST_LOG=info
EXPOSE 3000
VOLUME ["/app/data"]
CMD ["./ironpad"]

View File

@@ -1,6 +1,6 @@
MIT License MIT License
Copyright (c) 2026 Ola Proeis Copyright (c) 2026 Ola Prøis
Permission is hereby granted, free of charge, to any person obtaining a copy Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal of this software and associated documentation files (the "Software"), to deal

View File

@@ -1,18 +1,22 @@
# Ironpad # Ironpad
![Ironpad Banner](docs/graphics/ironpad-banner.png)
**A local-first, file-based project & knowledge management system.** **A local-first, file-based project & knowledge management system.**
![Build](https://github.com/OlaProeis/ironPad/actions/workflows/release.yml/badge.svg) ![Build](https://github.com/OlaProeis/ironPad/actions/workflows/release.yml/badge.svg)
![License: MIT](https://img.shields.io/badge/License-MIT-blue.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) ![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) ![Rust](https://img.shields.io/badge/rust-1.70%2B-orange)
![Version](https://img.shields.io/badge/version-0.1.0-green) ![Version](https://img.shields.io/badge/version-0.2.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 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) ![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). > **v0.2.0** -- Task comments, recurring tasks on calendar, system tray mode, and app branding. See [CHANGELOG.md](CHANGELOG.md) for details.
![What's New in v0.2.0](docs/graphics/whats-new-v020.png)
--- ---
@@ -22,14 +26,15 @@ Ironpad stores all your notes, projects, and tasks as plain Markdown files. No c
- **Local-first** -- Works fully offline, no internet required - **Local-first** -- Works fully offline, no internet required
- **Git integration** -- Automatic version control with 60-second commit batching, full diff viewer, push/fetch - **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 - **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 - **Project management** -- Organize tasks and notes by project with due dates, tags, subtasks, recurrence, and comments
- **Calendar view** -- Month grid showing tasks by due date with color-coded urgency - **Calendar view** -- Month grid showing tasks by due date with color-coded urgency; recurring tasks automatically expanded across the month
- **Dashboard** -- Cross-project overview with active task summaries - **Dashboard** -- Cross-project overview with active task summaries
- **Daily notes** -- Quick capture with templates for daily journaling - **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 - **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 - **External editing** -- Full support for VS Code, Obsidian, Vim, or any text editor
- **Search** -- ripgrep-powered full-text search across all files (Ctrl+K) - **Search** -- ripgrep-powered full-text search across all files (Ctrl+K)
- **Dark theme** -- Beautiful dark UI by default with light mode toggle - **Dark theme** -- Beautiful dark UI by default with light mode toggle
- **System tray** -- Runs quietly in the system tray (Windows, macOS, Linux); no console window in release builds
- **Tiny footprint** -- 5 MB binary, ~20 MB RAM, sub-second startup - **Tiny footprint** -- 5 MB binary, ~20 MB RAM, sub-second startup
## Quick Start ## Quick Start
@@ -63,8 +68,57 @@ npm run dev
Open http://localhost:5173 in your browser. Open http://localhost:5173 in your browser.
### Option 3: Run with Docker (Centralized Server)
This runs Ironpad as a single container that serves both API and frontend on port `3000`.
```bash
# Build and start in the background
docker compose up -d --build
# View logs
docker compose logs -f
```
Then open:
- `http://localhost:3000` from the same machine, or
- `http://<your-server-ip>:3000` from another device on your network.
Data persists in `./data` on the host via the compose volume mapping.
To stop:
```bash
docker compose down
```
#### Docker + Private Git Remote Sync
Ironpad can automatically sync the `data/` git repo with a private remote over SSH.
1. Put your SSH key files on the host (example: `./deploy/ssh/id_ed25519` and `./deploy/ssh/id_ed25519.pub`).
2. Uncomment the SSH volume mount and git env vars in `docker-compose.yml`.
3. Set:
- `IRONPAD_GIT_REMOTE_URL` (example: `git@github.com:your-org/ironpad-data.git`)
- `IRONPAD_GIT_SSH_PRIVATE_KEY` (path inside container)
- `IRONPAD_GIT_SSH_KNOWN_HOSTS` (optional; defaults to `/root/.ssh/known_hosts`)
- `IRONPAD_GIT_SYNC_INTERVAL_SECS` (example: `300`)
4. Recreate the stack:
```bash
docker compose up -d --build
```
Sync behavior:
- Every cycle: `fetch -> safe fast-forward if possible -> push`
- If local and remote diverge, auto fast-forward is skipped and a warning is logged.
- If libgit2 SSH auth fails, Ironpad can fall back to `git` CLI (controlled by `IRONPAD_GIT_USE_CLI_FALLBACK`, default `true`).
## Tech Stack ## Tech Stack
![Tech Stack](docs/graphics/tech-stack.png)
| Component | Technology | | Component | Technology |
|-----------|------------| |-----------|------------|
| Backend | Rust, Axum 0.8, Tokio | | Backend | Rust, Axum 0.8, Tokio |
@@ -78,8 +132,13 @@ Open http://localhost:5173 in your browser.
## Roadmap ## Roadmap
![Roadmap](docs/graphics/roadmap.png)
Ironpad is under active development. Here's what's planned: Ironpad is under active development. Here's what's planned:
- [x] Task comments and activity summary
- [x] Recurring tasks on calendar (daily/weekly/monthly/yearly expansion)
- [x] System tray mode (Windows, macOS, Linux)
- [ ] UI polish and animations - [ ] UI polish and animations
- [ ] Tag extraction and filtering across projects - [ ] Tag extraction and filtering across projects
- [ ] Backlinks between notes - [ ] Backlinks between notes
@@ -87,13 +146,14 @@ Ironpad is under active development. Here's what's planned:
- [ ] Export to PDF / HTML - [ ] Export to PDF / HTML
- [ ] Custom themes - [ ] Custom themes
- [ ] Global hotkey (Ctrl+Shift+Space) - [ ] Global hotkey (Ctrl+Shift+Space)
- [ ] System tray mode
- [ ] Kanban board view for tasks - [ ] Kanban board view for tasks
See [CHECKLIST.md](docs/ai-workflow/CHECKLIST.md) for detailed implementation status. See [CHECKLIST.md](docs/ai-workflow/CHECKLIST.md) for detailed implementation status.
## Built With AI ## Built With AI
![AI Workflow](docs/graphics/ai-workflow.png)
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. 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: Read about the method:
@@ -108,11 +168,24 @@ Read about the method:
|---------|---------|-------------| |---------|---------|-------------|
| Data directory | `data/` next to executable | Override with `IRONPAD_DATA_DIR` env var | | Data directory | `data/` next to executable | Override with `IRONPAD_DATA_DIR` env var |
| Backend port | 3000 (auto-increments to 3010) | Dynamic port selection | | Backend port | 3000 (auto-increments to 3010) | Dynamic port selection |
| Backend host | `127.0.0.1` | Override with `IRONPAD_HOST` (use `0.0.0.0` for Docker/server access) |
| Fixed port | disabled | Set `IRONPAD_PORT` to force a specific port |
| Disable tray mode | `false` | Set `IRONPAD_DISABLE_TRAY=1` to run headless in production static mode |
| Auto-commit | Every 60 seconds | Git commits when changes exist | | Auto-commit | Every 60 seconds | Git commits when changes exist |
| Git remote URL | not set | `IRONPAD_GIT_REMOTE_URL` creates/updates `origin` |
| Git sync interval | `0` (disabled) | Set `IRONPAD_GIT_SYNC_INTERVAL_SECS` to enable scheduled sync |
| Git SSH private key | not set | `IRONPAD_GIT_SSH_PRIVATE_KEY` path to private key in container |
| Git SSH public key | not set | Optional `IRONPAD_GIT_SSH_PUBLIC_KEY` path |
| Git known_hosts path | `/root/.ssh/known_hosts` | Override with `IRONPAD_GIT_SSH_KNOWN_HOSTS` |
| Git SSH username | `git` | Override with `IRONPAD_GIT_SSH_USERNAME` if needed |
| Git SSH passphrase | not set | Optional `IRONPAD_GIT_SSH_PASSPHRASE` |
| Git CLI fallback | `true` | `IRONPAD_GIT_USE_CLI_FALLBACK` for fetch/push auth fallback |
| Auto-save | 1 second debounce | Frontend saves after typing stops | | Auto-save | 1 second debounce | Frontend saves after typing stops |
## Documentation ## Documentation
![Architecture](docs/graphics/architecture.png)
| Document | Description | | Document | Description |
|----------|-------------| |----------|-------------|
| [docs/API.md](docs/API.md) | Complete REST API reference | | [docs/API.md](docs/API.md) | Complete REST API reference |

64
ROADMAP.md Normal file
View File

@@ -0,0 +1,64 @@
# Ironpad Roadmap
## Release 0.2.0 (Current)
### Features
#### 1. Task comments & activity summary
- **Comment section** per task with date-stamped entries
- Store comments as YAML sequence in task frontmatter
- **Last comment as summary** -- most recent comment shown in task list and dashboard cards
- Add/delete comments via API and UI, newest-first display with relative timestamps
#### 2. Recurring tasks on the calendar
- Tasks with daily/weekly recurrence now appear on the calendar (previously required explicit `due_date`)
- Recurring tasks expanded into the visible month grid (daily/weekly/monthly/yearly)
- Anchor date: `due_date` if set, otherwise `created`; respects `recurrence_interval`
- Recurring occurrences shown with dashed border and recurrence icon
#### 3. System tray mode
- System tray icon replaces CMD window (Windows, macOS, Linux)
- Tray menu: **Open in Browser** | **Quit**
- No console window on Windows in release builds
- Server runs on background thread; tray event loop on main thread (cross-platform safe)
#### 4. App branding
- Ironpad logo as system tray icon and Windows exe icon
- Favicon and logo in the web UI (browser tab + header)
---
## Suggested features (future releases)
Ideas that fit the current architecture and local-first design:
### High fit (0.3.x)
- **Calendar drag-and-drop** -- reschedule tasks by dragging onto a new date
- **Week / day calendar views** -- alternative to month view for denser task planning
- **Sort task list by due date / priority** -- alongside current created-date sorting
- **Overdue indicator** -- clearer overdue badge or count in sidebar and dashboard
### Medium fit (0.4.x)
- **Quick-add task** -- global or dashboard shortcut to create a task without opening a project
- **Bulk actions** -- complete multiple tasks, move section, add/remove tags in one go
- **Task templates** -- create tasks from predefined templates (e.g. "Meeting prep", "Review")
- **Tag extraction and cross-project filtering** -- surface and filter by tags across all projects
### Longer term
- UI polish and subtle animations
- Responsive sidebar / mobile-friendly layout
- Global hotkey (e.g. Ctrl+Shift+Space)
- Backlinks between notes
- Graph view of note connections
- Export to PDF / HTML
- Custom themes
- Kanban board view
---
## Version history
| Version | Status | Date | Notes |
|---------|----------|------------|----------------------------------------------------------|
| 0.1.0 | Released | 2025-12-01 | First public release, core features in place |
| 0.2.0 | Current | 2026-02-16 | Comments, recurring calendar, system tray, app branding |

View File

@@ -67,7 +67,7 @@ ironpad/
## Implemented Features ## Implemented Features
### Backend ### Backend
- API-only server (no frontend serving, no browser auto-open) - **Dual-mode server**: API-only in development; frontend-serving + system tray in production
- Dynamic port (3000-3010) - Dynamic port (3000-3010)
- Notes CRUD with atomic writes - Notes CRUD with atomic writes
- Frontmatter auto-management - Frontmatter auto-management
@@ -79,12 +79,13 @@ ironpad/
- Git remote info (ahead/behind tracking), fetch support - Git remote info (ahead/behind tracking), fetch support
- Projects API with notes management - Projects API with notes management
- **File-based Tasks API** — each task is a markdown file with frontmatter - **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 - Fields: id, title, completed, section, priority, due_date, is_active, tags, parent_id, recurrence, recurrence_interval, comments
- Rich text descriptions with markdown support - Rich text descriptions with markdown support
- Sorted by created date (stable ordering) - Sorted by created date (stable ordering)
- **Subtasks** — tasks with `parent_id` link to a parent task - **Subtasks** — tasks with `parent_id` link to a parent task
- **Tags** — YAML sequence in frontmatter, per-task labels for filtering - **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 - **Recurring tasks** — when completing a recurring task, auto-creates next instance with advanced due date
- **Comments** — date-stamped comment entries stored as YAML sequence in frontmatter; last comment shown as summary in list/dashboard
- Daily notes API (`/api/daily`, `/api/daily/today`, `/api/daily/:date`) - Daily notes API (`/api/daily`, `/api/daily/today`, `/api/daily/:date`)
- Assets API (upload + serve) - Assets API (upload + serve)
@@ -113,9 +114,14 @@ ironpad/
- **Tag filter bar** — click tags to filter task list - **Tag filter bar** — click tags to filter task list
- **Subtasks** — expandable subtasks under parent tasks, add subtask inline - **Subtasks** — expandable subtasks under parent tasks, add subtask inline
- **Recurrence picker** — set daily/weekly/monthly/yearly recurrence - **Recurrence picker** — set daily/weekly/monthly/yearly recurrence
- **Task comments** — date-stamped comments section in task detail, newest first, add/delete
- **Last comment summary** — most recent comment shown in task list and dashboard cards
- Inline title editing (double-click) - Inline title editing (double-click)
- **Calendar view** — month grid showing tasks by due date - **Calendar view** — month grid showing tasks by due date + recurring task expansion
- Tasks with due dates plotted on calendar cells - Tasks with due dates plotted on calendar cells
- **Recurring tasks expanded** — daily/weekly/monthly/yearly tasks shown on computed occurrences
- Recurring occurrences use anchor date (`due_date` or `created`), respect `recurrence_interval`
- Recurring entries shown with dashed border and ↻ icon to distinguish from regular tasks
- Daily notes shown as blue dots - Daily notes shown as blue dots
- Color-coded urgency (overdue, today, soon) - Color-coded urgency (overdue, today, soon)
- Month navigation + Today button - Month navigation + Today button
@@ -150,6 +156,8 @@ GET/POST /api/projects/:id/tasks
GET/PUT/DEL /api/projects/:id/tasks/:task_id GET/PUT/DEL /api/projects/:id/tasks/:task_id
PUT /api/projects/:id/tasks/:task_id/toggle PUT /api/projects/:id/tasks/:task_id/toggle
PUT /api/projects/:id/tasks/:task_id/meta PUT /api/projects/:id/tasks/:task_id/meta
POST /api/projects/:id/tasks/:task_id/comments
DELETE /api/projects/:id/tasks/:task_id/comments/:index
GET /api/tasks # All tasks across projects GET /api/tasks # All tasks across projects
GET /api/daily GET /api/daily
@@ -224,12 +232,25 @@ WS /ws
--- ---
## Not Yet Implemented (Phase 6+) ## Implemented in Phase 6
- **Recurring tasks on calendar** — frontend expands daily/weekly/monthly/yearly tasks into the visible month grid
- Anchor date: `due_date` if set, otherwise `created`; respects `recurrence_interval`
- Deduplicates against regular due-date entries; visual indicator (dashed border, ↻ icon)
- **System tray mode** — production binary runs in system tray (Windows, macOS, Linux)
- `#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]` hides console on Windows
- Server on background thread, tray event loop on main thread (cross-platform safe)
- Tray menu: "Open in Browser" / "Quit"
- Uses `tray-item` crate with platform-specific icon loading (`windows-sys` on Windows)
- Development mode unchanged (no tray, API-only)
---
## Not Yet Implemented (Phase 7+)
- UI polish and animations - UI polish and animations
- Responsive sidebar - Responsive sidebar
- Global hotkey (Ctrl+Shift+Space) - Global hotkey (Ctrl+Shift+Space)
- System tray mode
- Backlinks between notes - Backlinks between notes
- Graph view - Graph view
- Export (PDF / HTML) - Export (PDF / HTML)
@@ -262,12 +283,14 @@ WS /ws
| Project note ID | `{slug}-index` format | | Project note ID | `{slug}-index` format |
| Task storage | Individual .md files in `tasks/` folder | | Task storage | Individual .md files in `tasks/` folder |
| List sorting | By created date (stable, not affected by edits) | | List sorting | By created date (stable, not affected by edits) |
| Backend mode | API-only (no frontend serving) | | Backend mode | API-only (dev); frontend-serving + system tray (production) |
| Theme | Dark by default, toggle to light, persists to localStorage | | Theme | Dark by default, toggle to light, persists to localStorage |
| Tags | YAML sequence in frontmatter, project-scoped filtering | | Tags | YAML sequence in frontmatter, project-scoped filtering |
| Subtasks | Separate task files with `parent_id` field linking to parent | | Subtasks | Separate task files with `parent_id` field linking to parent |
| Recurring tasks | On completion, backend auto-creates next instance with advanced due date | | Recurring tasks | On completion, backend auto-creates next instance with advanced due date |
| Calendar | Pure frontend month grid, tasks filtered by `due_date` presence | | Calendar | Pure frontend month grid, tasks by `due_date` + recurring expansion |
| System tray | `tray-item` crate; main thread tray, background thread server; `windows-sys` for icon on Windows |
| Task comments | YAML sequence in frontmatter, date-stamped, last comment as list/dashboard summary |
| Dashboard | Home route `/`, loads all projects + all tasks for cross-project summary | | Dashboard | Home route `/`, loads all projects + all tasks for cross-project summary |
--- ---

1
backend/.gitignore vendored
View File

@@ -1 +1,2 @@
/target /target
/release

791
backend/Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
[package] [package]
name = "ironpad" name = "ironpad"
version = "0.1.0" version = "0.2.0"
edition = "2021" edition = "2021"
[dependencies] [dependencies]
@@ -46,4 +46,19 @@ uuid = { version = "1.0", features = ["v4"] }
# Utilities # Utilities
lazy_static = "1.4" lazy_static = "1.4"
tokio-util = { version = "0.7", features = ["io"] } tokio-util = { version = "0.7", features = ["io"] }
# System tray (production mode)
tray-item = "0.10"
# Linux: tray-item needs ksni feature (pure-Rust D-Bus StatusNotifierItem)
[target.'cfg(target_os = "linux")'.dependencies]
tray-item = { version = "0.10", features = ["ksni"] }
# Windows icon loading (for tray icon)
[target.'cfg(target_os = "windows")'.dependencies]
windows-sys = { version = "0.52", features = ["Win32_UI_WindowsAndMessaging", "Win32_System_LibraryLoader"] }
# Build dependencies (Windows icon embedding)
[target.'cfg(target_os = "windows")'.build-dependencies]
winresource = "0.1"

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

BIN
backend/assets/ironpad.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

10
backend/build.rs Normal file
View File

@@ -0,0 +1,10 @@
fn main() {
// On Windows, embed the application icon into the .exe
// This sets both the Explorer icon and makes it available as a resource
#[cfg(target_os = "windows")]
{
let mut res = winresource::WindowsResource::new();
res.set_icon("assets/ironpad.ico");
res.compile().expect("Failed to compile Windows resources");
}
}

View File

@@ -1,4 +1,7 @@
use std::net::SocketAddr; // Hide console window on Windows in release builds (production mode)
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
use std::net::{IpAddr, Ipv4Addr, SocketAddr};
use std::sync::Arc; use std::sync::Arc;
use axum::{routing::get, Router}; use axum::{routing::get, Router};
@@ -17,26 +20,74 @@ mod websocket;
/// Find an available port and return the bound listener. /// Find an available port and return the bound listener.
/// Avoids TOCTOU race by keeping the listener alive. /// Avoids TOCTOU race by keeping the listener alive.
async fn find_available_port() -> (TcpListener, u16) { async fn find_available_port() -> (TcpListener, u16) {
let host = std::env::var("IRONPAD_HOST").unwrap_or_else(|_| "127.0.0.1".to_string());
let bind_ip = match host.parse::<IpAddr>() {
Ok(ip) => ip,
Err(_) => {
warn!("Invalid IRONPAD_HOST '{}', falling back to 127.0.0.1", host);
IpAddr::V4(Ipv4Addr::new(127, 0, 0, 1))
}
};
if let Ok(port_str) = std::env::var("IRONPAD_PORT") {
match port_str.parse::<u16>() {
Ok(port) => {
let addr = SocketAddr::new(bind_ip, port);
let listener = TcpListener::bind(addr)
.await
.unwrap_or_else(|e| panic!("Failed to bind to {addr}: {e}"));
return (listener, port);
}
Err(_) => {
warn!("Invalid IRONPAD_PORT '{}', falling back to 3000-3010", port_str);
}
}
}
for port in 3000..=3010 { for port in 3000..=3010 {
let addr = SocketAddr::from(([127, 0, 0, 1], port)); let addr = SocketAddr::new(bind_ip, port);
if let Ok(listener) = TcpListener::bind(addr).await { if let Ok(listener) = TcpListener::bind(addr).await {
return (listener, port); return (listener, port);
} }
} }
panic!("No available ports in range 30003010"); panic!("No available ports in range 3000-3010");
} }
#[tokio::main] fn env_flag(name: &str) -> bool {
async fn main() { std::env::var(name)
// Logging .map(|v| matches!(v.to_ascii_lowercase().as_str(), "1" | "true" | "yes" | "on"))
tracing_subscriber::fmt().init(); .unwrap_or(false)
}
// Resolve data directory (production vs development mode) fn main() {
tracing_subscriber::fmt().init();
config::init_data_dir(); config::init_data_dir();
// Find port and bind (listener kept alive to avoid race condition) let production = config::is_production();
let disable_tray = env_flag("IRONPAD_DISABLE_TRAY");
if production && !disable_tray {
run_with_tray();
} else {
if production && disable_tray {
info!("Production static mode detected; running headless (IRONPAD_DISABLE_TRAY=1)");
}
// Development mode: normal tokio runtime, no tray
let rt = tokio::runtime::Runtime::new().expect("Failed to create tokio runtime");
rt.block_on(run_server(None));
}
}
/// Start the Axum server. In tray mode, sends the bound port through `port_tx`
/// before entering the serve loop.
async fn run_server(port_tx: Option<std::sync::mpsc::Sender<u16>>) {
let (listener, port) = find_available_port().await; let (listener, port) = find_available_port().await;
// Notify tray thread of the bound port
if let Some(tx) = port_tx {
let _ = tx.send(port);
}
// WebSocket state (shared across handlers) // WebSocket state (shared across handlers)
let ws_state = Arc::new(websocket::WsState::new()); let ws_state = Arc::new(websocket::WsState::new());
@@ -51,8 +102,14 @@ async fn main() {
warn!("Git init skipped: {}", e); warn!("Git init skipped: {}", e);
} }
// Configure git remote from env (if provided)
if let Err(e) = services::git::configure_remote_from_env() {
warn!("Git remote setup skipped: {}", e);
}
// Start auto-commit background task (tries to commit every 60s) // Start auto-commit background task (tries to commit every 60s)
services::git::start_auto_commit(); services::git::start_auto_commit();
services::git::start_auto_sync();
// CORS layer (permissive for local-only app) // CORS layer (permissive for local-only app)
let cors = CorsLayer::permissive(); let cors = CorsLayer::permissive();
@@ -94,7 +151,6 @@ async fn main() {
.layer(cors); .layer(cors);
// Check for embedded frontend (production mode) // Check for embedded frontend (production mode)
// Resolve relative to the executable's directory, not the working directory
let has_frontend = config::is_production(); let has_frontend = config::is_production();
if has_frontend { if has_frontend {
@@ -116,23 +172,114 @@ async fn main() {
} }
// Start server // Start server
info!("🚀 Ironpad running on http://localhost:{port}"); let bound_addr = listener
.local_addr()
// Auto-open browser in production mode .map(|a| a.to_string())
if has_frontend { .unwrap_or_else(|_| format!("127.0.0.1:{port}"));
let url = format!("http://localhost:{}", port); info!("Ironpad running on http://{bound_addr}");
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"); axum::serve(listener, app).await.expect("Server failed");
} }
// ---------------------------------------------------------------------------
// System tray (production mode)
// ---------------------------------------------------------------------------
/// Build a platform-appropriate tray icon.
///
/// On Windows the Ironpad icon is embedded in the .exe via winresource (build.rs).
/// We load it with LoadIconW using the resource ID assigned by winresource.
#[cfg(target_os = "windows")]
fn tray_icon() -> tray_item::IconSource {
let hicon = unsafe {
// winresource embeds the icon at resource ID 1.
// GetModuleHandleW(null) = current exe, MAKEINTRESOURCE(1) = 1 as PCWSTR.
let hinstance =
windows_sys::Win32::System::LibraryLoader::GetModuleHandleW(std::ptr::null());
windows_sys::Win32::UI::WindowsAndMessaging::LoadIconW(hinstance, 1 as *const u16)
};
tray_item::IconSource::RawIcon(hicon)
}
#[cfg(target_os = "macos")]
fn tray_icon() -> tray_item::IconSource {
tray_item::IconSource::Resource("")
}
#[cfg(target_os = "linux")]
fn tray_icon() -> tray_item::IconSource {
tray_item::IconSource::Resource("application-x-executable")
}
#[cfg(not(any(target_os = "windows", target_os = "macos", target_os = "linux")))]
fn tray_icon() -> tray_item::IconSource {
tray_item::IconSource::Resource("")
}
/// Production mode: run the server on a background thread, tray on main thread.
/// The main thread drives the tray event loop (required on macOS; safe everywhere).
fn run_with_tray() {
use std::sync::mpsc;
enum TrayMessage {
OpenBrowser,
Quit,
}
// Channel to receive the dynamically-bound port from the server thread
let (port_tx, port_rx) = mpsc::channel::<u16>();
// Start the Axum server on a background thread with its own tokio runtime
std::thread::spawn(move || {
let rt = tokio::runtime::Runtime::new().expect("Failed to create tokio runtime");
rt.block_on(run_server(Some(port_tx)));
});
// Wait for the server to report its port
let port = port_rx.recv().expect("Server failed to start");
let url = format!("http://localhost:{}", port);
// Auto-open browser after a short delay (non-blocking)
let url_for_open = url.clone();
std::thread::spawn(move || {
std::thread::sleep(std::time::Duration::from_millis(400));
let _ = webbrowser::open(&url_for_open);
});
// Set up system tray icon and menu
let (tx, rx) = mpsc::sync_channel::<TrayMessage>(2);
let mut tray = match tray_item::TrayItem::new("Ironpad", tray_icon()) {
Ok(t) => t,
Err(e) => {
eprintln!("Failed to create system tray: {}. Running headless.", e);
// Keep the process alive so the server thread continues
loop {
std::thread::park();
}
}
};
let tx_open = tx.clone();
let _ = tray.add_menu_item("Open in Browser", move || {
let _ = tx_open.send(TrayMessage::OpenBrowser);
});
let tx_quit = tx;
let _ = tray.add_menu_item("Quit", move || {
let _ = tx_quit.send(TrayMessage::Quit);
});
// Main-thread event loop — processes tray menu actions
for msg in rx {
match msg {
TrayMessage::OpenBrowser => {
let _ = webbrowser::open(&url);
}
TrayMessage::Quit => {
info!("Quit requested from system tray");
std::process::exit(0);
}
}
}
}

View File

@@ -10,9 +10,9 @@ use std::fs;
use crate::config; use crate::config;
use crate::routes::tasks::{ use crate::routes::tasks::{
create_task_handler, delete_task_handler, get_task_handler, list_project_tasks_handler, add_comment_handler, create_task_handler, delete_comment_handler, delete_task_handler,
toggle_task_handler, update_task_content_handler, update_task_meta_handler, CreateTaskRequest, get_task_handler, list_project_tasks_handler, toggle_task_handler, update_task_content_handler,
UpdateTaskMetaRequest, update_task_meta_handler, AddCommentRequest, CreateTaskRequest, UpdateTaskMetaRequest,
}; };
use crate::services::filesystem; use crate::services::filesystem;
use crate::services::frontmatter; use crate::services::frontmatter;
@@ -91,6 +91,14 @@ pub fn router() -> Router {
) )
.route("/{id}/tasks/{task_id}/toggle", put(toggle_project_task)) .route("/{id}/tasks/{task_id}/toggle", put(toggle_project_task))
.route("/{id}/tasks/{task_id}/meta", put(update_project_task_meta)) .route("/{id}/tasks/{task_id}/meta", put(update_project_task_meta))
.route(
"/{id}/tasks/{task_id}/comments",
axum::routing::post(add_project_task_comment),
)
.route(
"/{id}/tasks/{task_id}/comments/{comment_index}",
axum::routing::delete(delete_project_task_comment),
)
// Note routes // Note routes
.route( .route(
"/{id}/notes", "/{id}/notes",
@@ -143,6 +151,19 @@ async fn delete_project_task(Path((id, task_id)): Path<(String, String)>) -> imp
delete_task_handler(id, task_id).await delete_task_handler(id, task_id).await
} }
async fn add_project_task_comment(
Path((id, task_id)): Path<(String, String)>,
Json(payload): Json<AddCommentRequest>,
) -> impl IntoResponse {
add_comment_handler(id, task_id, payload).await
}
async fn delete_project_task_comment(
Path((id, task_id, comment_index)): Path<(String, String, usize)>,
) -> impl IntoResponse {
delete_comment_handler(id, task_id, comment_index).await
}
async fn list_projects() -> impl IntoResponse { async fn list_projects() -> impl IntoResponse {
match list_projects_impl() { match list_projects_impl() {
Ok(projects) => Json(projects).into_response(), Ok(projects) => Json(projects).into_response(),

View File

@@ -7,6 +7,13 @@ use crate::config;
use crate::services::filesystem; use crate::services::filesystem;
use crate::services::frontmatter; use crate::services::frontmatter;
/// A single comment entry on a task
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Comment {
pub date: String,
pub text: String,
}
/// Task summary for list views /// Task summary for list views
#[derive(Debug, Clone, Serialize)] #[derive(Debug, Clone, Serialize)]
pub struct Task { pub struct Task {
@@ -25,6 +32,7 @@ pub struct Task {
pub path: String, pub path: String,
pub created: String, pub created: String,
pub updated: String, pub updated: String,
pub last_comment: Option<String>,
} }
/// Task with full content for detail view /// Task with full content for detail view
@@ -46,6 +54,7 @@ pub struct TaskWithContent {
pub created: String, pub created: String,
pub updated: String, pub updated: String,
pub content: String, pub content: String,
pub comments: Vec<Comment>,
} }
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
@@ -67,6 +76,11 @@ pub struct UpdateTaskMetaRequest {
pub recurrence_interval: Option<u32>, pub recurrence_interval: Option<u32>,
} }
#[derive(Debug, Deserialize)]
pub struct AddCommentRequest {
pub text: String,
}
pub fn router() -> Router { pub fn router() -> Router {
Router::new().route("/", get(list_all_tasks_handler)) Router::new().route("/", get(list_all_tasks_handler))
} }
@@ -178,6 +192,41 @@ pub async fn delete_task_handler(project_id: String, task_id: String) -> impl In
} }
} }
/// Add a comment to a task
pub async fn add_comment_handler(
project_id: String,
task_id: String,
payload: AddCommentRequest,
) -> impl IntoResponse {
match add_comment_impl(&project_id, &task_id, &payload.text) {
Ok(task) => (StatusCode::CREATED, 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 add comment: {}", err),
)
.into_response(),
}
}
/// Delete a comment from a task by index
pub async fn delete_comment_handler(
project_id: String,
task_id: String,
comment_index: usize,
) -> impl IntoResponse {
match delete_comment_impl(&project_id, &task_id, comment_index) {
Ok(task) => Json(task).into_response(),
Err(err) if err.contains("not found") => (StatusCode::NOT_FOUND, err).into_response(),
Err(err) if err.contains("out of range") => (StatusCode::BAD_REQUEST, err).into_response(),
Err(err) => (
StatusCode::INTERNAL_SERVER_ERROR,
format!("Failed to delete comment: {}", err),
)
.into_response(),
}
}
// ============ Implementation Functions ============ // ============ Implementation Functions ============
fn get_tasks_dir(project_id: &str) -> std::path::PathBuf { fn get_tasks_dir(project_id: &str) -> std::path::PathBuf {
@@ -233,6 +282,29 @@ fn list_project_tasks_impl(project_id: &str) -> Result<Vec<Task>, String> {
Ok(tasks) Ok(tasks)
} }
/// Parse comments from frontmatter YAML sequence.
fn parse_comments(fm: &serde_yaml::Mapping) -> Vec<Comment> {
fm.get(&serde_yaml::Value::from("comments"))
.and_then(|v| v.as_sequence())
.map(|seq| {
seq.iter()
.filter_map(|item| {
let map = item.as_mapping()?;
let date = map
.get(&serde_yaml::Value::from("date"))
.and_then(|v| v.as_str())
.map(String::from)?;
let text = map
.get(&serde_yaml::Value::from("text"))
.and_then(|v| v.as_str())
.map(String::from)?;
Some(Comment { date, text })
})
.collect()
})
.unwrap_or_default()
}
/// Shared helper: extract common task fields from frontmatter. /// Shared helper: extract common task fields from frontmatter.
/// Eliminates duplication between parse_task_file and parse_task_with_content. /// 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 { fn extract_task_fields(fm: &serde_yaml::Mapping, path: &StdPath, project_id: &str) -> Task {
@@ -242,6 +314,9 @@ fn extract_task_fields(fm: &serde_yaml::Mapping, path: &StdPath, project_id: &st
.unwrap_or("") .unwrap_or("")
.to_string(); .to_string();
let comments = parse_comments(fm);
let last_comment = comments.last().map(|c| c.text.clone());
Task { Task {
id: frontmatter::get_str_or(fm, "id", &filename), id: frontmatter::get_str_or(fm, "id", &filename),
title: frontmatter::get_str_or(fm, "title", "Untitled"), title: frontmatter::get_str_or(fm, "title", "Untitled"),
@@ -258,6 +333,7 @@ fn extract_task_fields(fm: &serde_yaml::Mapping, path: &StdPath, project_id: &st
path: format!("projects/{}/tasks/{}.md", project_id, filename), path: format!("projects/{}/tasks/{}.md", project_id, filename),
created: frontmatter::get_str_or(fm, "created", ""), created: frontmatter::get_str_or(fm, "created", ""),
updated: frontmatter::get_str_or(fm, "updated", ""), updated: frontmatter::get_str_or(fm, "updated", ""),
last_comment,
} }
} }
@@ -355,6 +431,7 @@ fn create_task_impl(
created: now_str.clone(), created: now_str.clone(),
updated: now_str, updated: now_str,
content: body, content: body,
comments: Vec::new(),
}) })
} }
@@ -407,6 +484,7 @@ fn parse_task_with_content(
project_id: &str, project_id: &str,
) -> Result<TaskWithContent, String> { ) -> Result<TaskWithContent, String> {
let task = extract_task_fields(fm, path, project_id); let task = extract_task_fields(fm, path, project_id);
let comments = parse_comments(fm);
Ok(TaskWithContent { Ok(TaskWithContent {
id: task.id, id: task.id,
title: task.title, title: task.title,
@@ -424,6 +502,7 @@ fn parse_task_with_content(
created: task.created, created: task.created,
updated: task.updated, updated: task.updated,
content: body.to_string(), content: body.to_string(),
comments,
}) })
} }
@@ -683,6 +762,7 @@ fn create_recurring_task_impl(
created: now_str.clone(), created: now_str.clone(),
updated: now_str, updated: now_str,
content: body, content: body,
comments: Vec::new(),
}) })
} }
@@ -776,6 +856,102 @@ fn update_task_meta_impl(
Ok(task) Ok(task)
} }
/// Serialize a Vec<Comment> into a YAML sequence Value.
fn comments_to_yaml(comments: &[Comment]) -> serde_yaml::Value {
let seq: Vec<serde_yaml::Value> = comments
.iter()
.map(|c| {
let mut map = serde_yaml::Mapping::new();
map.insert(
serde_yaml::Value::from("date"),
serde_yaml::Value::from(c.date.as_str()),
);
map.insert(
serde_yaml::Value::from("text"),
serde_yaml::Value::from(c.text.as_str()),
);
serde_yaml::Value::Mapping(map)
})
.collect();
serde_yaml::Value::Sequence(seq)
}
fn add_comment_impl(
project_id: &str,
task_id: &str,
text: &str,
) -> Result<TaskWithContent, String> {
let task_path = find_task_path(project_id, task_id)?;
let existing = fs::read_to_string(&task_path).map_err(|e| e.to_string())?;
let (mut fm, body, _) = frontmatter::parse_frontmatter(&existing);
// Parse existing comments and append the new one
let mut comments = parse_comments(&fm);
let now = chrono::Utc::now().to_rfc3339();
comments.push(Comment {
date: now.clone(),
text: text.to_string(),
});
// Write comments back to frontmatter
fm.insert(
serde_yaml::Value::from("comments"),
comments_to_yaml(&comments),
);
// Update timestamp
fm.insert(
serde_yaml::Value::from("updated"),
serde_yaml::Value::from(now),
);
let new_content = frontmatter::serialize_frontmatter(&fm, &body)?;
filesystem::atomic_write(&task_path, new_content.as_bytes())?;
parse_task_with_content(&fm, &body, &task_path, project_id)
}
fn delete_comment_impl(
project_id: &str,
task_id: &str,
comment_index: usize,
) -> Result<TaskWithContent, String> {
let task_path = find_task_path(project_id, task_id)?;
let existing = fs::read_to_string(&task_path).map_err(|e| e.to_string())?;
let (mut fm, body, _) = frontmatter::parse_frontmatter(&existing);
let mut comments = parse_comments(&fm);
if comment_index >= comments.len() {
return Err("Comment index out of range".to_string());
}
comments.remove(comment_index);
// Write comments back (or remove key if empty)
if comments.is_empty() {
fm.remove(&serde_yaml::Value::from("comments"));
} else {
fm.insert(
serde_yaml::Value::from("comments"),
comments_to_yaml(&comments),
);
}
// Update timestamp
let now = chrono::Utc::now().to_rfc3339();
fm.insert(
serde_yaml::Value::from("updated"),
serde_yaml::Value::from(now),
);
let new_content = frontmatter::serialize_frontmatter(&fm, &body)?;
filesystem::atomic_write(&task_path, new_content.as_bytes())?;
parse_task_with_content(&fm, &body, &task_path, project_id)
}
fn delete_task_impl(project_id: &str, task_id: &str) -> Result<(), String> { fn delete_task_impl(project_id: &str, task_id: &str) -> Result<(), String> {
let task_path = find_task_path(project_id, task_id)?; let task_path = find_task_path(project_id, task_id)?;

View File

@@ -1,3 +1,5 @@
use std::path::{Path, PathBuf};
use std::process::Command;
use std::time::Duration; use std::time::Duration;
use chrono::Utc; use chrono::Utc;
@@ -96,6 +98,171 @@ pub struct RemoteInfo {
/// The background task simply tries to commit every interval; /// The background task simply tries to commit every interval;
/// commit_all() already handles "no changes" gracefully. /// commit_all() already handles "no changes" gracefully.
#[derive(Debug, Clone)]
struct GitAuthConfig {
username: String,
private_key: Option<PathBuf>,
public_key: Option<PathBuf>,
passphrase: Option<String>,
}
fn git_auth_config() -> GitAuthConfig {
let username = std::env::var("IRONPAD_GIT_SSH_USERNAME").unwrap_or_else(|_| "git".to_string());
let private_key = std::env::var("IRONPAD_GIT_SSH_PRIVATE_KEY")
.ok()
.map(PathBuf::from)
.filter(|p| p.exists());
let public_key = std::env::var("IRONPAD_GIT_SSH_PUBLIC_KEY")
.ok()
.map(PathBuf::from)
.filter(|p| p.exists());
let passphrase = std::env::var("IRONPAD_GIT_SSH_PASSPHRASE")
.ok()
.and_then(|s| {
let trimmed = s.trim().to_string();
if trimmed.is_empty() {
None
} else {
Some(trimmed)
}
});
GitAuthConfig {
username,
private_key,
public_key,
passphrase,
}
}
fn remote_callbacks() -> git2::RemoteCallbacks<'static> {
let auth = git_auth_config();
let mut callbacks = git2::RemoteCallbacks::new();
callbacks.credentials(move |_url, username_from_url, _allowed_types| {
// Always prefer configured username (env-driven).
// Some remotes embed a username in URL; that can cause auth mismatches.
let username = auth.username.as_str();
if let Some(url_user) = username_from_url {
if url_user != username {
tracing::warn!(
"Remote URL username '{}' differs from IRONPAD_GIT_SSH_USERNAME '{}'; using env value",
url_user,
username
);
}
}
if let Some(private_key) = auth.private_key.as_deref() {
let public_key: Option<&Path> = auth.public_key.as_deref();
let passphrase = auth.passphrase.as_deref();
// First try with configured public key path (if provided),
// then retry without public key file to avoid mismatch issues.
if let Some(pub_key_path) = public_key {
match git2::Cred::ssh_key(username, Some(pub_key_path), private_key, passphrase) {
Ok(cred) => return Ok(cred),
Err(e) => {
tracing::warn!(
"SSH key auth with explicit public key failed for user '{}', private '{}', public '{}': {}",
username,
private_key.display(),
pub_key_path.display(),
e
);
}
}
}
match git2::Cred::ssh_key(username, None, private_key, passphrase) {
Ok(cred) => return Ok(cred),
Err(e) => {
tracing::warn!(
"SSH key auth from private key failed for user '{}', key '{}': {}",
username,
private_key.display(),
e
);
}
}
} else {
tracing::warn!(
"IRONPAD_GIT_SSH_PRIVATE_KEY not set or file missing; falling back to SSH agent"
);
}
git2::Cred::ssh_key_from_agent(username)
});
callbacks
}
fn use_git_cli_fallback() -> bool {
std::env::var("IRONPAD_GIT_USE_CLI_FALLBACK")
.map(|v| !matches!(v.to_ascii_lowercase().as_str(), "0" | "false" | "no" | "off"))
.unwrap_or(true)
}
fn known_hosts_path() -> Option<PathBuf> {
if let Ok(path) = std::env::var("IRONPAD_GIT_SSH_KNOWN_HOSTS") {
let p = PathBuf::from(path);
if p.exists() {
return Some(p);
}
}
let default = PathBuf::from("/root/.ssh/known_hosts");
if default.exists() {
Some(default)
} else {
None
}
}
fn git_ssh_command(auth: &GitAuthConfig) -> Option<String> {
let private_key = auth.private_key.as_ref()?;
let mut cmd = format!(
"ssh -i {} -o IdentitiesOnly=yes -o StrictHostKeyChecking=yes",
private_key.display()
);
if let Some(known_hosts) = known_hosts_path() {
cmd.push_str(&format!(" -o UserKnownHostsFile={}", known_hosts.display()));
}
Some(cmd)
}
fn run_git_cli(args: &[&str]) -> Result<(), String> {
let auth = git_auth_config();
let data_path = config::data_dir();
let mut cmd = Command::new("git");
cmd.args(args)
.current_dir(data_path)
.env("GIT_TERMINAL_PROMPT", "0");
if let Some(ssh_cmd) = git_ssh_command(&auth) {
cmd.env("GIT_SSH_COMMAND", ssh_cmd);
}
let output = cmd
.output()
.map_err(|e| format!("Failed to run git CLI: {}", e))?;
if output.status.success() {
Ok(())
} else {
let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string();
let msg = if !stderr.is_empty() { stderr } else { stdout };
Err(format!(
"git {} failed (exit {}): {}",
args.join(" "),
output.status.code().unwrap_or(-1),
msg
))
}
}
/// Get repository status /// Get repository status
pub fn get_status() -> Result<RepoStatus, String> { pub fn get_status() -> Result<RepoStatus, String> {
let data_path = config::data_dir(); let data_path = config::data_dir();
@@ -317,27 +484,33 @@ pub fn push_to_remote() -> Result<(), String> {
return Err("No remote URL configured".to_string()); 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 // Set up push options
let mut push_options = git2::PushOptions::new(); let mut push_options = git2::PushOptions::new();
push_options.remote_callbacks(callbacks); push_options.remote_callbacks(remote_callbacks());
// Push the current branch // Push the current branch
let refspec = format!("refs/heads/{}:refs/heads/{}", branch_name, branch_name); let refspec = format!("refs/heads/{}:refs/heads/{}", branch_name, branch_name);
remote match remote.push(&[&refspec], Some(&mut push_options)) {
.push(&[&refspec], Some(&mut push_options)) Ok(_) => {
.map_err(|e| format!("Push failed: {}. Make sure SSH keys are configured.", e))?; tracing::info!("Successfully pushed to origin/{}", branch_name);
Ok(())
tracing::info!("Successfully pushed to origin/{}", branch_name); }
Ok(()) Err(e) => {
let err_text = e.to_string();
if !use_git_cli_fallback() {
return Err(format!(
"Push failed: {}. Make sure SSH keys are configured.",
err_text
));
}
tracing::warn!("libgit2 push failed, trying git CLI fallback: {}", err_text);
run_git_cli(&["push", "origin", branch_name]).map_err(|cli_err| {
format!("Push failed: {} (libgit2) / {} (git CLI)", err_text, cli_err)
})?;
tracing::info!("Successfully pushed to origin/{} (git CLI fallback)", branch_name);
Ok(())
}
}
} }
/// Check if remote is configured /// Check if remote is configured
@@ -351,6 +524,30 @@ pub fn has_remote() -> bool {
false false
} }
/// Configure `origin` from IRONPAD_GIT_REMOTE_URL.
/// If `origin` exists, updates its URL. Otherwise creates it.
pub fn configure_remote_from_env() -> Result<(), String> {
let remote_url = match std::env::var("IRONPAD_GIT_REMOTE_URL") {
Ok(url) if !url.trim().is_empty() => url,
_ => return Ok(()),
};
let data_path = config::data_dir();
let repo = Repository::open(data_path).map_err(|e| format!("Not a git repository: {}", e))?;
if repo.find_remote("origin").is_ok() {
repo.remote_set_url("origin", &remote_url)
.map_err(|e| format!("Failed to update origin URL: {}", e))?;
tracing::info!("Updated git remote origin from IRONPAD_GIT_REMOTE_URL");
} else {
repo.remote("origin", &remote_url)
.map_err(|e| format!("Failed to create origin remote: {}", e))?;
tracing::info!("Configured git remote origin from IRONPAD_GIT_REMOTE_URL");
}
Ok(())
}
/// Start auto-commit background task. /// Start auto-commit background task.
/// Tries to commit every 60 seconds; commit_all() already handles "no changes" gracefully. /// Tries to commit every 60 seconds; commit_all() already handles "no changes" gracefully.
pub fn start_auto_commit() { pub fn start_auto_commit() {
@@ -374,6 +571,111 @@ pub fn start_auto_commit() {
}); });
} }
fn fast_forward_to_upstream(repo: &Repository, branch_name: &str) -> Result<(), String> {
let local_branch = repo
.find_branch(branch_name, git2::BranchType::Local)
.map_err(|e| format!("Local branch not found: {}", e))?;
let upstream_branch = match local_branch.upstream() {
Ok(upstream) => upstream,
Err(_) => return Ok(()),
};
let local_oid = match local_branch.get().target() {
Some(oid) => oid,
None => return Ok(()),
};
let upstream_oid = match upstream_branch.get().target() {
Some(oid) => oid,
None => return Ok(()),
};
let (ahead, behind) = repo
.graph_ahead_behind(local_oid, upstream_oid)
.map_err(|e| e.to_string())?;
if behind == 0 {
return Ok(());
}
if ahead > 0 {
tracing::warn!(
"Remote is ahead by {} and local is ahead by {}; skipping auto fast-forward",
behind,
ahead
);
return Ok(());
}
let refname = format!("refs/heads/{}", branch_name);
repo.reference(&refname, upstream_oid, true, "ironpad auto fast-forward")
.map_err(|e| format!("Failed to move local branch ref: {}", e))?;
repo.set_head(&refname)
.map_err(|e| format!("Failed to set HEAD: {}", e))?;
let mut checkout = git2::build::CheckoutBuilder::new();
checkout.force();
repo.checkout_head(Some(&mut checkout))
.map_err(|e| format!("Failed to checkout fast-forwarded HEAD: {}", e))?;
tracing::info!("Fast-forwarded local branch {} by {} commit(s)", branch_name, behind);
Ok(())
}
fn sync_once() -> Result<(), String> {
configure_remote_from_env()?;
if !has_remote() {
return Ok(());
}
fetch_from_remote()?;
// Try to fast-forward local branch to upstream when safe (no divergence).
let data_path = config::data_dir();
let repo = Repository::open(data_path).map_err(|e| format!("Not a git repository: {}", e))?;
if let Ok(head) = repo.head() {
if let Some(branch_name) = head.shorthand() {
let _ = fast_forward_to_upstream(&repo, branch_name);
}
}
// Push local commits (if any) after fetching.
if let Err(e) = push_to_remote() {
if !(e.contains("non-fast-forward") || e.contains("rejected")) {
return Err(e);
}
tracing::warn!("Auto-sync push skipped: {}", e);
}
Ok(())
}
/// Start periodic remote sync.
/// Controlled via env vars:
/// - IRONPAD_GIT_SYNC_INTERVAL_SECS: sync interval in seconds (default 0 = disabled)
/// - IRONPAD_GIT_REMOTE_URL: optional remote URL used to create/update `origin`
pub fn start_auto_sync() {
let interval_secs = std::env::var("IRONPAD_GIT_SYNC_INTERVAL_SECS")
.ok()
.and_then(|s| s.parse::<u64>().ok())
.unwrap_or(0);
if interval_secs == 0 {
tracing::info!("Git auto-sync disabled (set IRONPAD_GIT_SYNC_INTERVAL_SECS > 0 to enable)");
return;
}
tokio::spawn(async move {
let mut ticker = interval(Duration::from_secs(interval_secs));
loop {
ticker.tick().await;
if let Err(e) = sync_once() {
tracing::warn!("Git auto-sync failed: {}", e);
}
}
});
}
/// Get commit history (most recent first) /// Get commit history (most recent first)
pub fn get_log(limit: Option<usize>) -> Result<Vec<CommitDetail>, String> { pub fn get_log(limit: Option<usize>) -> Result<Vec<CommitDetail>, String> {
let data_path = config::data_dir(); let data_path = config::data_dir();
@@ -630,18 +932,20 @@ pub fn fetch_from_remote() -> Result<(), String> {
.find_remote("origin") .find_remote("origin")
.map_err(|e| format!("Remote 'origin' not found: {}", e))?; .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(); let mut fetch_options = git2::FetchOptions::new();
fetch_options.remote_callbacks(callbacks); fetch_options.remote_callbacks(remote_callbacks());
remote match remote.fetch(&[] as &[&str], Some(&mut fetch_options), None) {
.fetch(&[] as &[&str], Some(&mut fetch_options), None) Ok(_) => Ok(()),
.map_err(|e| format!("Fetch failed: {}", e))?; Err(e) => {
let err_text = e.to_string();
Ok(()) if !use_git_cli_fallback() {
return Err(format!("Fetch failed: {}", err_text));
}
tracing::warn!("libgit2 fetch failed, trying git CLI fallback: {}", err_text);
run_git_cli(&["fetch", "origin", "--prune"]).map_err(|cli_err| {
format!("Fetch failed: {} (libgit2) / {} (git CLI)", err_text, cli_err)
})
}
}
} }

40
build-local.ps1 Normal file
View File

@@ -0,0 +1,40 @@
# build-local.ps1 — Build a local release package for testing
# Creates: release/ironpad.exe + release/static/
# Usage: .\build-local.ps1
$ErrorActionPreference = "Stop"
$root = $PSScriptRoot
Write-Host "`n=== Ironpad Local Build ===" -ForegroundColor Cyan
# 1. Build frontend
Write-Host "`n[1/4] Building frontend..." -ForegroundColor Yellow
Push-Location "$root\frontend"
npm run build
if ($LASTEXITCODE -ne 0) { Pop-Location; throw "Frontend build failed" }
Pop-Location
# 2. Build backend (release)
Write-Host "`n[2/4] Building backend (release)..." -ForegroundColor Yellow
Push-Location "$root\backend"
cargo build --release
if ($LASTEXITCODE -ne 0) { Pop-Location; throw "Backend build failed" }
Pop-Location
# 3. Assemble release folder
Write-Host "`n[3/4] Assembling release folder..." -ForegroundColor Yellow
$releaseDir = "$root\release"
if (Test-Path $releaseDir) { Remove-Item -Recurse -Force $releaseDir }
New-Item -ItemType Directory -Path $releaseDir | Out-Null
New-Item -ItemType Directory -Path "$releaseDir\static" | Out-Null
Copy-Item "$root\backend\target\release\ironpad.exe" "$releaseDir\ironpad.exe"
Copy-Item "$root\frontend\dist\*" "$releaseDir\static\" -Recurse
# 4. Done
$size = [math]::Round((Get-Item "$releaseDir\ironpad.exe").Length / 1MB, 1)
Write-Host "`n[4/4] Done!" -ForegroundColor Green
Write-Host " Binary: $releaseDir\ironpad.exe ($size MB)"
Write-Host " Static: $releaseDir\static\"
Write-Host "`nTo run: cd release && .\ironpad.exe" -ForegroundColor Cyan
Write-Host "(Tray icon will appear - right-click for menu)`n"

0
deploy/ssh/.gitkeep Normal file
View File

32
docker-compose.yml Normal file
View File

@@ -0,0 +1,32 @@
services:
ironpad:
container_name: ironpad
restart: unless-stopped
build:
context: .
dockerfile: Dockerfile
environment:
IRONPAD_HOST: "0.0.0.0"
IRONPAD_PORT: "3000"
IRONPAD_DISABLE_TRAY: "1"
IRONPAD_DATA_DIR: "/app/data"
RUST_LOG: "info"
# Git sync (optional)
# IRONPAD_GIT_REMOTE_URL: "git@github.com:your-org/your-private-repo.git"
# IRONPAD_GIT_SSH_USERNAME: "git"
# IRONPAD_GIT_SSH_PRIVATE_KEY: "/run/secrets/ironpad_ssh/id_ed25519"
# IRONPAD_GIT_SSH_PUBLIC_KEY: "/run/secrets/ironpad_ssh/id_ed25519.pub"
# IRONPAD_GIT_SSH_KNOWN_HOSTS: "/run/secrets/ironpad_ssh/known_hosts"
# IRONPAD_GIT_SSH_PASSPHRASE: ""
# IRONPAD_GIT_SYNC_INTERVAL_SECS: "300"
# IRONPAD_GIT_USE_CLI_FALLBACK: "true"
ports:
- "3000:3000"
volumes:
- ./data:/app/data
# Mount SSH key material read-only for private remote auth
# - ./deploy/ssh:/run/secrets/ironpad_ssh:ro

View File

@@ -267,7 +267,12 @@ GET /api/projects/:id/tasks
"priority": "high", "priority": "high",
"due_date": "2026-02-10", "due_date": "2026-02-10",
"is_active": true, "is_active": true,
"content": "## Requirements\n\n- Item 1\n- Item 2", "tags": ["backend", "api"],
"parent_id": null,
"recurrence": null,
"recurrence_interval": null,
"project_id": "ferrite",
"last_comment": "API endpoint done, moving to frontend",
"path": "projects/ferrite/tasks/task-20260205-123456.md", "path": "projects/ferrite/tasks/task-20260205-123456.md",
"created": "2026-02-05T12:34:56Z", "created": "2026-02-05T12:34:56Z",
"updated": "2026-02-05T12:34:56Z" "updated": "2026-02-05T12:34:56Z"
@@ -338,6 +343,72 @@ PUT /api/projects/:id/tasks/:taskId/toggle
DELETE /api/projects/:id/tasks/:taskId DELETE /api/projects/:id/tasks/:taskId
``` ```
### Add Comment
```http
POST /api/projects/:id/tasks/:taskId/comments
Content-Type: application/json
{
"text": "Started work on this API integration is in progress."
}
```
**Response:** `201 Created`
```json
{
"id": "task-20260216-120000",
"title": "Implement feature X",
"completed": false,
"section": "Active",
"is_active": true,
"comments": [
{
"date": "2026-02-16T10:30:00+00:00",
"text": "Created initial spec"
},
{
"date": "2026-02-16T12:00:00+00:00",
"text": "Started work on this — API integration is in progress."
}
],
"content": "## Requirements\n\n- Item 1\n- Item 2",
"...": "other task fields"
}
```
Comments are stored as a YAML sequence in the task's frontmatter. The response returns the full `TaskWithContent` object with all comments.
### Delete Comment
```http
DELETE /api/projects/:id/tasks/:taskId/comments/:commentIndex
```
Removes the comment at the given zero-based index.
**Response:**
```json
{
"id": "task-20260216-120000",
"comments": [],
"...": "full TaskWithContent"
}
```
### Comment in List Views
When listing tasks (`GET /api/projects/:id/tasks` or `GET /api/tasks`), each task includes a `last_comment` field with the text of the most recent comment (or `null` if no comments exist). This enables showing a quick status summary without loading the full task.
```json
{
"id": "task-20260216-120000",
"title": "Implement feature X",
"last_comment": "Started work on this — API integration is in progress.",
"...": "other task fields"
}
```
--- ---
## All Tasks ## All Tasks

View File

@@ -320,6 +320,11 @@ interface Project {
### Task ### Task
```typescript ```typescript
interface Comment {
date: string // ISO 8601 timestamp
text: string // Comment body
}
interface Task { interface Task {
id: string // e.g., "task-20260205-123456" id: string // e.g., "task-20260205-123456"
title: string title: string
@@ -328,13 +333,52 @@ interface Task {
priority?: string priority?: string
due_date?: string due_date?: string
is_active: boolean is_active: boolean
content: string // Markdown description tags: string[]
parent_id?: string // Links subtask to parent
recurrence?: string // "daily" | "weekly" | "monthly" | "yearly"
recurrence_interval?: number
last_comment?: string // Most recent comment text (list views)
path: string path: string
created: string created: string
updated: string updated: string
} }
interface TaskWithContent extends Task {
content: string // Markdown description
comments: Comment[] // Full comment history
}
``` ```
#### Task File Format
```markdown
---
id: ferrite-task-20260216-120000
type: task
title: Implement feature X
completed: false
section: Active
priority: normal
is_active: true
tags:
- backend
- api
comments:
- date: "2026-02-16T10:30:00+00:00"
text: Started initial research
- date: "2026-02-16T14:00:00+00:00"
text: API endpoint done, moving to frontend
created: "2026-02-16T12:00:00+00:00"
updated: "2026-02-16T14:00:00+00:00"
---
# Implement feature X
Detailed description in markdown...
```
Comments are stored as a YAML sequence directly in frontmatter, keeping everything in a single file. The `last_comment` field in list views is derived at read time from the last entry in the sequence.
## API Design ## API Design
### REST Conventions ### REST Conventions

View File

@@ -0,0 +1,8 @@
{
"recent_files": [],
"expanded_paths": [
"G:\\DEV\\proman2k\\docs\\ai-workflow"
],
"file_tree_width": 250.0,
"show_file_tree": true
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.9 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.8 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.7 MiB

BIN
docs/graphics/roadmap.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.7 MiB

View File

@@ -0,0 +1,108 @@
# System Tray Implementation (v0.2.0) ✅
**Status:** Implemented
**Goal:** Replace the CMD window with a system tray icon. Users interact via tray menu: "Open in Browser" or "Quit". No console window on Windows.
---
## Overview
- **Scope:** Single codebase, cross-platform. CI/CD unchanged — same build pipeline produces one binary per OS.
- **Crate:** `tray-item = "0.10"` (cross-platform tray icon with simple API)
- **Windows icon:** `windows-sys = "0.52"` for loading the standard application icon via `LoadIconW`
---
## What Was Implemented
### 1. Dependencies (`backend/Cargo.toml`)
```toml
# System tray (production mode)
tray-item = "0.10"
# Windows system icon loading (for tray icon)
[target.'cfg(target_os = "windows")'.dependencies]
windows-sys = { version = "0.52", features = ["Win32_UI_WindowsAndMessaging"] }
```
### 2. Windows: Hide Console Window (`backend/src/main.rs`)
```rust
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
```
- **Debug builds (`cargo run`):** Console remains for logs.
- **Release builds (`cargo build --release`):** No CMD window on Windows.
### 3. Restructured `main()` for Dual-Mode Operation
```
main()
├── Development mode (no static/index.html next to exe)
│ └── Normal tokio runtime on main thread, API-only, no tray
└── Production mode (static/index.html exists)
└── run_with_tray()
├── Background thread: tokio runtime + Axum server
├── mpsc channel: server sends bound port back to main thread
├── Auto-open browser (400ms delay)
└── Main thread: tray icon + event loop
```
### 4. Tray Icon (Platform-Specific)
| Platform | Icon Source |
|----------|------------|
| Windows | `IDI_APPLICATION` via `LoadIconW` (standard system app icon) |
| macOS | `IconSource::Resource("")` (default icon) |
| Linux | `IconSource::Resource("application-x-executable")` (icon theme) |
### 5. Tray Menu
- **"Open in Browser"** — calls `webbrowser::open()` with `http://localhost:{port}`
- **"Quit"** — calls `std::process::exit(0)` to shut down server and exit
### 6. Threading Model
The tray event loop runs on the **main thread** (required on macOS, safe on all platforms). The Axum server runs on a **background thread** with its own `tokio::runtime::Runtime`. Port discovery uses `std::sync::mpsc::channel`.
---
## Behaviour Summary
| Before (v0.1.0) | After (v0.2.0) |
|------------------------|------------------------------------|
| CMD window visible | No console window (Windows release)|
| Browser opens on start | Browser opens on start + via tray |
| Quit via Ctrl+C | Quit via tray menu or Ctrl+C (dev) |
---
## Testing Checklist
- [x] Backend compiles with new dependencies (`cargo check`)
- [ ] Windows: No CMD window when running release binary
- [ ] Windows: Tray icon appears; "Open in Browser" opens correct URL
- [ ] Windows: "Quit" exits cleanly
- [ ] macOS: Tray icon in menu bar; menu works
- [ ] Linux: Tray icon in system tray; menu works
- [ ] Development mode (`cargo run`): Behaviour unchanged (no tray, API-only)
---
## Icon Asset
Currently using platform default icons (Windows system app icon, macOS default, Linux icon theme). To use a custom branded icon:
1. Create a 16×16 or 32×32 PNG/ICO
2. On Windows: embed via `.rc` resource file and `build.rs`, use `IconSource::Resource("icon-name")`
3. On macOS/Linux: use `IconSource::Data { width, height, data }` with raw RGBA bytes
---
## References
- [tray-item crate](https://crates.io/crates/tray-item)
- [tray-icon crate](https://crates.io/crates/tray-icon) (alternative, heavier)
- `#![windows_subsystem = "windows"]` — [Rust conditional compilation](https://doc.rust-lang.org/reference/conditional-compilation.html#windows_subsystem)

View File

@@ -6,6 +6,8 @@
<meta http-equiv="Cache-Control" content="no-cache, no-store, must-revalidate" /> <meta http-equiv="Cache-Control" content="no-cache, no-store, must-revalidate" />
<meta http-equiv="Pragma" content="no-cache" /> <meta http-equiv="Pragma" content="no-cache" />
<meta http-equiv="Expires" content="0" /> <meta http-equiv="Expires" content="0" />
<link rel="icon" href="/favicon.ico" type="image/x-icon" />
<link rel="apple-touch-icon" href="/logo-180.png" />
<title>Ironpad</title> <title>Ironpad</title>
<script> <script>
// Apply saved theme immediately to prevent flash // Apply saved theme immediately to prevent flash

View File

@@ -1,7 +1,7 @@
{ {
"name": "frontend", "name": "frontend",
"private": true, "private": true,
"version": "0.0.0", "version": "0.2.0",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",

BIN
frontend/public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

BIN
frontend/public/logo-32.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

View File

@@ -15,7 +15,8 @@ import type {
CommitDetail, CommitDetail,
DiffInfo, DiffInfo,
RemoteInfo, RemoteInfo,
DailyNote DailyNote,
Comment
} from '../types' } from '../types'
const API_BASE = '/api' const API_BASE = '/api'
@@ -150,6 +151,20 @@ export const tasksApi = {
delete: (projectId: string, taskId: string) => delete: (projectId: string, taskId: string) =>
request<void>(`/projects/${encodeURIComponent(projectId)}/tasks/${encodeURIComponent(taskId)}`, { request<void>(`/projects/${encodeURIComponent(projectId)}/tasks/${encodeURIComponent(taskId)}`, {
method: 'DELETE' method: 'DELETE'
}),
// Add a comment to a task
addComment: (projectId: string, taskId: string, text: string) =>
request<TaskWithContent>(`/projects/${encodeURIComponent(projectId)}/tasks/${encodeURIComponent(taskId)}/comments`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ text })
}),
// Delete a comment from a task by index
deleteComment: (projectId: string, taskId: string, commentIndex: number) =>
request<TaskWithContent>(`/projects/${encodeURIComponent(projectId)}/tasks/${encodeURIComponent(taskId)}/comments/${commentIndex}`, {
method: 'DELETE'
}) })
} }

View File

@@ -65,7 +65,10 @@ function goHome() {
<template> <template>
<header class="topbar"> <header class="topbar">
<div class="topbar-left"> <div class="topbar-left">
<h1 class="app-title" @click="goHome" style="cursor: pointer" title="Dashboard">Ironpad</h1> <h1 class="app-title" @click="goHome" style="cursor: pointer" title="Dashboard">
<img src="/logo-32.png" alt="" class="app-logo" />
Ironpad
</h1>
<div class="project-selector"> <div class="project-selector">
<button class="project-button" @click="toggleDropdown"> <button class="project-button" @click="toggleDropdown">
@@ -175,6 +178,15 @@ function goHome() {
font-weight: 600; font-weight: 600;
margin: 0; margin: 0;
color: var(--color-text); color: var(--color-text);
display: flex;
align-items: center;
gap: 8px;
}
.app-logo {
width: 22px;
height: 22px;
object-fit: contain;
} }
.project-selector { .project-selector {

View File

@@ -181,6 +181,42 @@ export const useTasksStore = defineStore('tasks', () => {
} }
} }
async function addComment(projectId: string, taskId: string, text: string) {
try {
error.value = null
const task = await tasksApi.addComment(projectId, taskId, text)
selectedTask.value = task
// Refresh task list so last_comment updates
if (currentProjectId.value === projectId) {
await loadProjectTasks(projectId)
}
return task
} catch (err) {
error.value = `Failed to add comment: ${err}`
throw err
}
}
async function deleteComment(projectId: string, taskId: string, commentIndex: number) {
try {
error.value = null
const task = await tasksApi.deleteComment(projectId, taskId, commentIndex)
selectedTask.value = task
// Refresh task list so last_comment updates
if (currentProjectId.value === projectId) {
await loadProjectTasks(projectId)
}
return task
} catch (err) {
error.value = `Failed to delete comment: ${err}`
throw err
}
}
function selectTask(task: Task | null) { function selectTask(task: Task | null) {
if (task && currentProjectId.value) { if (task && currentProjectId.value) {
loadTask(currentProjectId.value, task.id) loadTask(currentProjectId.value, task.id)
@@ -227,6 +263,8 @@ export const useTasksStore = defineStore('tasks', () => {
toggleTask, toggleTask,
updateTaskMeta, updateTaskMeta,
deleteTask, deleteTask,
addComment,
deleteComment,
selectTask, selectTask,
clearSelectedTask, clearSelectedTask,
clearProjectTasks, clearProjectTasks,

View File

@@ -40,6 +40,11 @@ export interface ProjectNoteWithContent extends ProjectNote {
content: string content: string
} }
export interface Comment {
date: string
text: string
}
export interface Task { export interface Task {
id: string id: string
title: string title: string
@@ -56,10 +61,12 @@ export interface Task {
path: string path: string
created: string created: string
updated: string updated: string
last_comment?: string
} }
export interface TaskWithContent extends Task { export interface TaskWithContent extends Task {
content: string content: string
comments: Comment[]
} }
export interface SearchResult { export interface SearchResult {

View File

@@ -93,21 +93,144 @@ function formatDate(y: number, m: number, d: number): string {
return `${y}-${String(m + 1).padStart(2, '0')}-${String(d).padStart(2, '0')}` return `${y}-${String(m + 1).padStart(2, '0')}-${String(d).padStart(2, '0')}`
} }
// Tasks grouped by due date // Calendar entry: a task plus whether it's a recurring occurrence (vs a real due_date)
interface CalendarEntry {
task: Task
isRecurring: boolean
}
// Tasks grouped by due date (regular, non-recurring placements)
const tasksByDate = computed(() => { const tasksByDate = computed(() => {
const map = new Map<string, Task[]>() const map = new Map<string, CalendarEntry[]>()
for (const task of tasksStore.allTasks) { for (const task of tasksStore.allTasks) {
if (task.due_date && !task.completed) { if (task.due_date && !task.completed) {
const existing = map.get(task.due_date) || [] const existing = map.get(task.due_date) || []
existing.push(task) existing.push({ task, isRecurring: false })
map.set(task.due_date, existing) map.set(task.due_date, existing)
} }
} }
return map return map
}) })
function getTasksForDate(dateStr: string): Task[] { // Expand recurring tasks into the visible calendar range
return tasksByDate.value.get(dateStr) || [] const recurringByDate = computed(() => {
const map = new Map<string, CalendarEntry[]>()
const days = calendarDays.value
if (days.length === 0) return map
const rangeStart = days[0].date
const rangeEnd = days[days.length - 1].date
for (const task of tasksStore.allTasks) {
if (task.completed || !task.recurrence) continue
const interval = task.recurrence_interval || 1
const anchorStr = task.due_date || task.created?.split('T')[0]
if (!anchorStr) continue
const anchor = new Date(anchorStr + 'T00:00:00')
const start = new Date(rangeStart + 'T00:00:00')
const end = new Date(rangeEnd + 'T00:00:00')
const occurrences: string[] = []
switch (task.recurrence) {
case 'daily': {
let cur = new Date(anchor)
// Advance to range start (skip past occurrences efficiently)
if (cur < start) {
const daysBehind = Math.floor((start.getTime() - cur.getTime()) / 86400000)
const skipCycles = Math.floor(daysBehind / interval) * interval
cur.setDate(cur.getDate() + skipCycles)
}
while (cur <= end && occurrences.length < 60) {
if (cur >= start) {
occurrences.push(dateFromObj(cur))
}
cur.setDate(cur.getDate() + interval)
}
break
}
case 'weekly': {
const targetDow = anchor.getDay()
let cur = new Date(start)
// Find first matching weekday in range
while (cur.getDay() !== targetDow && cur <= end) {
cur.setDate(cur.getDate() + 1)
}
while (cur <= end && occurrences.length < 10) {
// Check interval alignment (weeks since anchor)
const msDiff = cur.getTime() - anchor.getTime()
const weeksDiff = Math.round(msDiff / (7 * 86400000))
if (weeksDiff >= 0 && weeksDiff % interval === 0) {
occurrences.push(dateFromObj(cur))
}
cur.setDate(cur.getDate() + 7)
}
break
}
case 'monthly': {
const targetDay = anchor.getDate()
// Check months visible in the calendar range (usually 3: prev, current, next)
for (let mOffset = -1; mOffset <= 1; mOffset++) {
let y = currentYear.value
let m = currentMonth.value + mOffset
if (m < 0) { m = 11; y-- }
if (m > 11) { m = 0; y++ }
const daysInM = new Date(y, m + 1, 0).getDate()
const day = Math.min(targetDay, daysInM)
const dateStr = formatDate(y, m, day)
if (dateStr >= rangeStart && dateStr <= rangeEnd) {
const monthsDiff = (y - anchor.getFullYear()) * 12 + (m - anchor.getMonth())
if (monthsDiff >= 0 && monthsDiff % interval === 0) {
occurrences.push(dateStr)
}
}
}
break
}
case 'yearly': {
const tgtMonth = anchor.getMonth()
const tgtDay = anchor.getDate()
const y = currentYear.value
const dateStr = formatDate(y, tgtMonth, tgtDay)
if (dateStr >= rangeStart && dateStr <= rangeEnd) {
const yearsDiff = y - anchor.getFullYear()
if (yearsDiff >= 0 && yearsDiff % interval === 0) {
occurrences.push(dateStr)
}
}
break
}
}
for (const dateStr of occurrences) {
// Skip if the task already appears on this date via its due_date
if (task.due_date === dateStr) continue
const existing = map.get(dateStr) || []
existing.push({ task, isRecurring: true })
map.set(dateStr, existing)
}
}
return map
})
function dateFromObj(d: Date): string {
return formatDate(d.getFullYear(), d.getMonth(), d.getDate())
}
// Merge regular due-date tasks and recurring occurrences for a given date
function getEntriesForDate(dateStr: string): CalendarEntry[] {
const regular = tasksByDate.value.get(dateStr) || []
const recurring = recurringByDate.value.get(dateStr) || []
return [...regular, ...recurring]
}
// Convenience: check if a date has any tasks (for cell styling)
function hasTasksOnDate(dateStr: string): boolean {
return getEntriesForDate(dateStr).length > 0
} }
function hasDailyNote(dateStr: string): boolean { function hasDailyNote(dateStr: string): boolean {
@@ -211,7 +334,7 @@ onMounted(async () => {
:class="['calendar-cell', { :class="['calendar-cell', {
'other-month': !day.isCurrentMonth, 'other-month': !day.isCurrentMonth,
'is-today': day.isToday, 'is-today': day.isToday,
'has-tasks': getTasksForDate(day.date).length > 0, 'has-tasks': hasTasksOnDate(day.date),
}]" }]"
> >
<div class="cell-header" @click="clickDate(day.date)"> <div class="cell-header" @click="clickDate(day.date)">
@@ -220,20 +343,21 @@ onMounted(async () => {
</div> </div>
<div class="cell-tasks"> <div class="cell-tasks">
<div <div
v-for="task in getTasksForDate(day.date).slice(0, 3)" v-for="entry in getEntriesForDate(day.date).slice(0, 3)"
:key="task.id" :key="`${entry.task.id}-${entry.isRecurring ? 'r' : 'd'}`"
:class="['cell-task', formatDueClass(day.date)]" :class="['cell-task', formatDueClass(day.date), { recurring: entry.isRecurring }]"
@click.stop="clickTask(task)" @click.stop="clickTask(entry.task)"
:title="`${projectName(task.project_id)}: ${task.title}`" :title="`${projectName(entry.task.project_id)}: ${entry.task.title}${entry.isRecurring ? ' (recurring)' : ''}`"
> >
{{ task.title }} <span v-if="entry.isRecurring" class="recurring-icon" title="Recurring">&#x21bb;</span>
{{ entry.task.title }}
</div> </div>
<div <div
v-if="getTasksForDate(day.date).length > 3" v-if="getEntriesForDate(day.date).length > 3"
class="cell-more" class="cell-more"
@click="clickDate(day.date)" @click="clickDate(day.date)"
> >
+{{ getTasksForDate(day.date).length - 3 }} more +{{ getEntriesForDate(day.date).length - 3 }} more
</div> </div>
</div> </div>
</div> </div>
@@ -413,6 +537,17 @@ onMounted(async () => {
border-left-color: var(--color-warning); border-left-color: var(--color-warning);
} }
.cell-task.recurring {
border-left-style: dashed;
opacity: 0.85;
}
.recurring-icon {
font-size: 10px;
margin-right: 2px;
opacity: 0.7;
}
.cell-more { .cell-more {
font-size: 10px; font-size: 10px;
color: var(--color-text-secondary); color: var(--color-text-secondary);

View File

@@ -140,17 +140,24 @@ onMounted(async () => {
@click="goToTask(project.id, task)" @click="goToTask(project.id, task)"
> >
<span class="task-checkbox">&#9744;</span> <span class="task-checkbox">&#9744;</span>
<span class="task-title">{{ task.title }}</span> <div class="card-task-info">
<div class="task-meta"> <div class="card-task-row">
<span <span class="task-title">{{ task.title }}</span>
v-for="tag in task.tags?.slice(0, 2)" <div class="task-meta">
:key="tag" <span
class="task-tag" v-for="tag in task.tags?.slice(0, 2)"
>{{ tag }}</span> :key="tag"
<span class="task-tag"
v-if="task.due_date && formatDueDate(task.due_date)" >{{ tag }}</span>
:class="['task-due', formatDueDate(task.due_date)?.class]" <span
>{{ formatDueDate(task.due_date)?.text }}</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="task.last_comment" class="card-task-comment">
{{ task.last_comment }}
</div>
</div> </div>
</div> </div>
<div <div
@@ -304,6 +311,20 @@ onMounted(async () => {
font-size: 14px; font-size: 14px;
} }
.card-task-info {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 2px;
}
.card-task-row {
display: flex;
align-items: center;
gap: 8px;
}
.card-task-item .task-title { .card-task-item .task-title {
flex: 1; flex: 1;
font-size: 13px; font-size: 13px;
@@ -313,6 +334,16 @@ onMounted(async () => {
min-width: 0; min-width: 0;
} }
.card-task-comment {
font-size: 11px;
color: var(--color-text-secondary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
font-style: italic;
opacity: 0.75;
}
.task-meta { .task-meta {
display: flex; display: flex;
gap: 4px; gap: 4px;

View File

@@ -338,6 +338,59 @@ async function addSubtask() {
} }
} }
// ============ Comment Management ============
const newCommentText = ref('')
const showCommentForm = ref(false)
function openCommentForm() {
showCommentForm.value = true
newCommentText.value = ''
}
function closeCommentForm() {
showCommentForm.value = false
newCommentText.value = ''
}
async function addComment() {
if (!newCommentText.value.trim() || !tasksStore.selectedTask) return
try {
await tasksStore.addComment(projectId.value, tasksStore.selectedTask.id, newCommentText.value.trim())
closeCommentForm()
} catch {
// Error handled in store
}
}
async function deleteComment(index: number) {
if (!tasksStore.selectedTask) return
try {
await tasksStore.deleteComment(projectId.value, tasksStore.selectedTask.id, index)
} catch {
// Error handled in store
}
}
function formatCommentDate(dateStr: string): string {
try {
const date = new Date(dateStr)
const now = new Date()
const diffMs = now.getTime() - date.getTime()
const diffMins = Math.floor(diffMs / (1000 * 60))
const diffHours = Math.floor(diffMs / (1000 * 60 * 60))
const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24))
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()
} catch {
return dateStr
}
}
// ============ Due Date Management ============ // ============ Due Date Management ============
async function setDueDate(date: string) { async function setDueDate(date: string) {
@@ -650,6 +703,9 @@ onUnmounted(() => {
{{ formatDueDate(task.due_date)?.text }} {{ formatDueDate(task.due_date)?.text }}
</span> </span>
</div> </div>
<div v-if="task.last_comment" class="task-last-comment">
{{ task.last_comment }}
</div>
</div> </div>
</template> </template>
<button <button
@@ -718,6 +774,9 @@ onUnmounted(() => {
{{ formatDueDate(task.due_date)?.text }} {{ formatDueDate(task.due_date)?.text }}
</span> </span>
</div> </div>
<div v-if="task.last_comment" class="task-last-comment">
{{ task.last_comment }}
</div>
</div> </div>
</template> </template>
<button <button
@@ -921,6 +980,49 @@ onUnmounted(() => {
<button class="tag-add-btn" @click="openSubtaskInput">+ Add subtask</button> <button class="tag-add-btn" @click="openSubtaskInput">+ Add subtask</button>
</div> </div>
<!-- Comments Section -->
<div class="comments-panel">
<div class="comments-header">
<span class="comments-label">Comments ({{ tasksStore.selectedTask.comments?.length || 0 }})</span>
<button v-if="!showCommentForm" class="tag-add-btn" @click="openCommentForm">+ Comment</button>
</div>
<!-- Add comment form -->
<div v-if="showCommentForm" class="comment-form">
<textarea
v-model="newCommentText"
class="comment-input"
placeholder="Add a comment or status update..."
rows="2"
@keydown.ctrl.enter="addComment"
@keyup.escape="closeCommentForm"
autofocus
></textarea>
<div class="comment-form-actions">
<button class="primary small" @click="addComment" :disabled="!newCommentText.trim()">Add Comment</button>
<button class="small" @click="closeCommentForm">Cancel</button>
<span class="comment-hint">Ctrl+Enter to submit</span>
</div>
</div>
<!-- Comment list (newest first) -->
<div v-if="tasksStore.selectedTask.comments?.length > 0" class="comment-list">
<div
v-for="(comment, index) in [...tasksStore.selectedTask.comments].reverse()"
:key="index"
class="comment-item"
>
<div class="comment-meta">
<span class="comment-date">{{ formatCommentDate(comment.date) }}</span>
<button
class="comment-delete"
@click="deleteComment(tasksStore.selectedTask.comments.length - 1 - index)"
title="Delete comment"
>&times;</button>
</div>
<div class="comment-text">{{ comment.text }}</div>
</div>
</div>
</div>
<div class="editor-content"> <div class="editor-content">
<div class="editor-pane"> <div class="editor-pane">
<MilkdownEditor <MilkdownEditor
@@ -1507,6 +1609,22 @@ button.small {
color: white; color: white;
} }
/* Last comment preview in task list */
.task-last-comment {
font-size: 11px;
color: var(--color-text-secondary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 100%;
opacity: 0.75;
font-style: italic;
}
.task-item.selected .task-last-comment {
color: rgba(255, 255, 255, 0.7);
}
/* Tag Editor Bar (detail panel) */ /* Tag Editor Bar (detail panel) */
.tag-editor-bar { .tag-editor-bar {
display: flex; display: flex;
@@ -1601,6 +1719,118 @@ button.small {
background: var(--color-bg-hover); background: var(--color-bg-hover);
} }
/* Comments Panel */
.comments-panel {
border-bottom: 1px solid var(--color-border);
padding: 8px 16px;
flex-shrink: 0;
max-height: 280px;
overflow-y: auto;
}
.comments-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 6px;
}
.comments-label {
font-size: 11px;
font-weight: 600;
color: var(--color-text-secondary);
text-transform: uppercase;
letter-spacing: 0.3px;
}
.comment-form {
margin-bottom: 8px;
}
.comment-input {
width: 100%;
padding: 8px 10px;
border: 1px solid var(--color-border);
border-radius: 6px;
background: var(--color-bg);
color: var(--color-text);
font-size: 13px;
font-family: inherit;
outline: none;
resize: vertical;
min-height: 48px;
box-sizing: border-box;
}
.comment-input:focus {
border-color: var(--color-primary);
}
.comment-form-actions {
display: flex;
gap: 8px;
align-items: center;
margin-top: 6px;
}
.comment-hint {
font-size: 11px;
color: var(--color-text-secondary);
margin-left: auto;
}
.comment-list {
display: flex;
flex-direction: column;
gap: 6px;
}
.comment-item {
padding: 8px 10px;
border-radius: 6px;
background: var(--color-bg);
border: 1px solid var(--color-border);
}
.comment-meta {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 4px;
}
.comment-date {
font-size: 11px;
color: var(--color-text-secondary);
}
.comment-delete {
padding: 0 4px;
border: none;
background: transparent;
color: var(--color-text-secondary);
cursor: pointer;
font-size: 14px;
line-height: 1;
opacity: 0;
transition: opacity 0.15s;
}
.comment-item:hover .comment-delete {
opacity: 1;
}
.comment-delete:hover {
color: var(--color-danger);
}
.comment-text {
font-size: 13px;
line-height: 1.5;
white-space: pre-wrap;
word-break: break-word;
}
/* Error Banner */ /* Error Banner */
.error-banner { .error-banner {
position: absolute; position: absolute;