diff --git a/.wolf/anatomy.md b/.wolf/anatomy.md index 1f0be1c9..d0f6840c 100644 --- a/.wolf/anatomy.md +++ b/.wolf/anatomy.md @@ -1,12 +1,13 @@ # anatomy.md -> Auto-maintained by OpenWolf. Last scanned: 2026-06-13T05:09:47.000Z -> Files: 382 tracked | Anatomy hits: 0 | Misses: 0 +> Auto-maintained by OpenWolf. Last scanned: 2026-06-13T12:33:44.844Z +> Files: 383 tracked | Anatomy hits: 0 | Misses: 0 ## ../../../../tmp/ - `codestral_ctx_test.py` — average, ask (~1020 tok) - `multiline_test.py` — ask, show, process (~588 tok) +- `resolve_merge.sh` (~354 tok) - `sanitize_check.py` — Mirror of src/app/event_loop/async_results/ai.rs::sanitize_inline_suggestion (~490 tok) ## ../../.claude/projects/-Users-qc-bright-Project-netherize-editor/memory/ @@ -204,7 +205,7 @@ ## config/ - `.DS_Store` (~1640 tok) -- `ai.toml` (~76 tok) +- `ai.toml` (~75 tok) ## config/keymaps/ @@ -348,7 +349,7 @@ ## src/app/event_loop/ -- `application.rs` — Delay before the which-key overlay appears for a pending chord — long (~27029 tok) +- `application.rs` — Delay before the which-key overlay appears for a pending chord — long (~29219 tok) - `commands_ai_chat.rs` — Like [`ai_slash_command_completion`] but selects the Nth matching command. (~11819 tok) - `commands_completion.rs` (~15455 tok) - `commands_editor.rs` (~5335 tok) @@ -383,7 +384,7 @@ ## src/app/input/ -- `handler.rs` — InputHandler: new, update_modifiers, on_focus_changed, clear_pending_prefix (~20114 tok) +- `handler.rs` — InputHandler: new, update_modifiers, on_focus_changed, clear_pending_prefix (~20241 tok) - `helpers.rs` — Trả về true nếu đây là event modifier-only (ShiftLeft, ShiftRight, ControlLeft...). (~4366 tok) - `mod.rs` (~69 tok) - `model.rs` — Dữ liệu key đã chuẩn hóa để tách phần "đọc winit event" (~764 tok) diff --git a/.wolf/buglog.json b/.wolf/buglog.json index 60c736ff..757b0abb 100644 --- a/.wolf/buglog.json +++ b/.wolf/buglog.json @@ -2140,6 +2140,38 @@ "related_bugs": [], "occurrences": 1, "last_seen": "2026-06-13T05:08:00.569Z" + }, + { + "id": "bug-125", + "timestamp": "2026-06-13T12:27:27.346Z", + "error_message": "Significant refactor of ", + "file": "config/ai.toml", + "root_cause": "7 lines replaced/restructured", + "fix": "Rewrote 17→7 lines (7 removed)", + "tags": [ + "auto-detected", + "refactor", + "toml" + ], + "related_bugs": [], + "occurrences": 1, + "last_seen": "2026-06-13T12:27:27.346Z" + }, + { + "id": "bug-126", + "timestamp": "2026-06-13T12:33:44.852Z", + "error_message": "Significant refactor of ", + "file": "src/app/input/handler.rs", + "root_cause": "18 lines replaced/restructured", + "fix": "Rewrote 34→8 lines (18 removed)", + "tags": [ + "auto-detected", + "refactor", + "rs" + ], + "related_bugs": [], + "occurrences": 1, + "last_seen": "2026-06-13T12:33:44.852Z" } ] } \ No newline at end of file diff --git a/.wolf/cron-state.json b/.wolf/cron-state.json index 29a50d3b..92d8da8b 100644 --- a/.wolf/cron-state.json +++ b/.wolf/cron-state.json @@ -187,6 +187,12 @@ "status": "success", "timestamp": "2026-05-30T05:00:00.316Z", "duration_ms": 81 + }, + { + "task_id": "anatomy-rescan", + "status": "success", + "timestamp": "2026-05-30T11:00:00.790Z", + "duration_ms": 124 } ], "dead_letter_queue": [], diff --git a/.wolf/hooks/_session.json b/.wolf/hooks/_session.json index a80d8366..6b136d7b 100644 --- a/.wolf/hooks/_session.json +++ b/.wolf/hooks/_session.json @@ -1,12 +1,83 @@ { "session_id": "session-2026-06-13-1918", "started": "2026-06-13T12:18:25.553Z", - "files_read": {}, - "files_written": [], - "edit_counts": {}, - "anatomy_hits": 0, + "files_read": { + "/Users/qc-bright/Project/netherize_editor/AGENTS.md": { + "count": 1, + "tokens": 1490, + "first_read": "2026-06-13T12:27:11.013Z" + }, + "/Users/qc-bright/Project/netherize_editor/config/ai.toml": { + "count": 1, + "tokens": 76, + "first_read": "2026-06-13T12:27:18.909Z" + }, + "/Users/qc-bright/Project/netherize_editor/src/app/event_loop/application.rs": { + "count": 2, + "tokens": 29259, + "first_read": "2026-06-13T12:32:12.153Z" + }, + "/Users/qc-bright/Project/netherize_editor/src/app/input/handler.rs": { + "count": 1, + "tokens": 20114, + "first_read": "2026-06-13T12:33:24.668Z" + } + }, + "files_written": [ + { + "file": "/Users/qc-bright/Project/netherize_editor/CLAUDE.md", + "action": "edit", + "tokens": 58, + "at": "2026-06-13T12:27:00.010Z" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/AGENTS.md", + "action": "edit", + "tokens": 58, + "at": "2026-06-13T12:27:16.174Z" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/config/ai.toml", + "action": "edit", + "tokens": 45, + "at": "2026-06-13T12:27:27.345Z" + }, + { + "file": "/tmp/resolve_merge.sh", + "action": "create", + "tokens": 380, + "at": "2026-06-13T12:31:47.837Z" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/event_loop/application.rs", + "action": "edit", + "tokens": 82, + "at": "2026-06-13T12:32:21.576Z" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/event_loop/application.rs", + "action": "edit", + "tokens": 33, + "at": "2026-06-13T12:32:31.158Z" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/input/handler.rs", + "action": "edit", + "tokens": 93, + "at": "2026-06-13T12:33:44.851Z" + } + ], + "edit_counts": { + "CLAUDE.md": 1, + "AGENTS.md": 1, + "config/ai.toml": 1, + "../../../../tmp/resolve_merge.sh": 1, + "src/app/event_loop/application.rs": 2, + "src/app/input/handler.rs": 1 + }, + "anatomy_hits": 4, "anatomy_misses": 0, - "repeated_reads_warned": 0, + "repeated_reads_warned": 1, "cerebrum_warnings": 0, - "stop_count": 0 + "stop_count": 1 } \ No newline at end of file diff --git a/.wolf/memory.md b/.wolf/memory.md index f773830b..b8726d69 100644 --- a/.wolf/memory.md +++ b/.wolf/memory.md @@ -1236,3 +1236,11 @@ | Time | Action | File(s) | Outcome | ~Tokens | |------|--------|---------|---------|--------| +| 19:27 | Edited CLAUDE.md | 5→1 lines | ~54 | +| 19:27 | Edited AGENTS.md | 5→1 lines | ~54 | +| 19:27 | Edited config/ai.toml | reduced (-10 lines) | ~42 | +| 19:31 | Created ../../../../tmp/resolve_merge.sh | — | ~354 | +| 19:32 | Edited src/app/event_loop/application.rs | 9→6 lines | ~76 | +| 19:32 | Edited src/app/event_loop/application.rs | 7→2 lines | ~31 | +| 19:33 | Edited src/app/input/handler.rs | removed 34 lines | ~87 | +| 19:44 | Session end: 7 writes across 6 files (CLAUDE.md, AGENTS.md, ai.toml, resolve_merge.sh, application.rs) | 4 reads | ~51688 tok | diff --git a/.wolf/token-ledger.json b/.wolf/token-ledger.json index baa788c0..9c58df27 100644 --- a/.wolf/token-ledger.json +++ b/.wolf/token-ledger.json @@ -2,14 +2,14 @@ "version": 1, "created_at": "2026-05-16T05:48:05.030Z", "lifetime": { - "total_tokens_estimated": 12636927, - "total_reads": 1318, - "total_writes": 2120, + "total_tokens_estimated": 12688615, + "total_reads": 1322, + "total_writes": 2127, "total_sessions": 86, - "anatomy_hits": 1233, + "anatomy_hits": 1237, "anatomy_misses": 40, - "repeated_reads_blocked": 1581, - "estimated_savings_vs_bare_cli": 18167079 + "repeated_reads_blocked": 1582, + "estimated_savings_vs_bare_cli": 18197138 }, "sessions": [ { @@ -20991,6 +20991,82 @@ "repeated_reads_blocked": 10, "anatomy_lookups": 10 } + }, + { + "id": "session-2026-06-13-1918", + "started": "2026-06-13T12:18:25.553Z", + "ended": "2026-06-13T12:44:14.157Z", + "reads": [ + { + "file": "/Users/qc-bright/Project/netherize_editor/AGENTS.md", + "tokens_estimated": 1490, + "was_repeated": false, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/config/ai.toml", + "tokens_estimated": 76, + "was_repeated": false, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/event_loop/application.rs", + "tokens_estimated": 29259, + "was_repeated": true, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/input/handler.rs", + "tokens_estimated": 20114, + "was_repeated": false, + "anatomy_had_description": false + } + ], + "writes": [ + { + "file": "/Users/qc-bright/Project/netherize_editor/CLAUDE.md", + "tokens_estimated": 58, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/AGENTS.md", + "tokens_estimated": 58, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/config/ai.toml", + "tokens_estimated": 45, + "action": "edit" + }, + { + "file": "/tmp/resolve_merge.sh", + "tokens_estimated": 380, + "action": "create" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/event_loop/application.rs", + "tokens_estimated": 82, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/event_loop/application.rs", + "tokens_estimated": 33, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/input/handler.rs", + "tokens_estimated": 93, + "action": "edit" + } + ], + "totals": { + "input_tokens_estimated": 50939, + "output_tokens_estimated": 749, + "reads_count": 4, + "writes_count": 7, + "repeated_reads_blocked": 1, + "anatomy_lookups": 4 + } } ], "daemon_usage": [], diff --git a/AGENTS.md b/AGENTS.md index 819474f8..8cb1c7e0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -64,7 +64,7 @@ For any input-to-action feature, you MUST follow this exact path: # GitNexus — Code Intelligence -This project is indexed by GitNexus as **netherize_editor** (7678 symbols, 20136 relationships, 300 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely. +This project is indexed by GitNexus as **netherize_editor** (8048 symbols, 21242 relationships, 300 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely. > If any GitNexus tool warns the index is stale, run `npx gitnexus analyze` in terminal first. diff --git a/CLAUDE.md b/CLAUDE.md index a267ffb4..89dfb0fa 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -71,7 +71,7 @@ For any input-to-action feature, you MUST follow this exact path: # GitNexus — Code Intelligence -This project is indexed by GitNexus as **netherize_editor** (7678 symbols, 20136 relationships, 300 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely. +This project is indexed by GitNexus as **netherize_editor** (8048 symbols, 21242 relationships, 300 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely. > If any GitNexus tool warns the index is stale, run `npx gitnexus analyze` in terminal first. diff --git a/debug.md b/debug.md new file mode 100644 index 00000000..a85bda9e --- /dev/null +++ b/debug.md @@ -0,0 +1,167 @@ + +# Kiến trúc Built-in Debugger: Hệ thống Generic DAP Client trong Netherize Editor + +Để debugger không chỉ chạy riêng cho Flutter mà trở thành một hạ tầng cốt lõi (Built-in Subsystem) của Netherize Editor, phục vụ cho việc debug mọi ngôn ngữ sau này (Rust, C++, Go, Python...), chúng ta sẽ thiết kế hệ thống theo chuẩn **Debug Adapter Protocol (DAP)** của Microsoft. + +Kiến trúc này tách biệt hoàn toàn phần giao diện (UI), lõi Editor (Core) và phần giao thức kết nối với các trình gỡ lỗi đặc thù của từng ngôn ngữ (Debug Adapters). + +--- + +## 1. Sơ Đồ Kiến Trúc Hạ Tầng DAP Client + +```mermaid +graph TD + UI[Giao diện DAP UI & Editor Gutter] <--> |AppState / Command Dispatch| Core[Editor Core Engine] + Core <--> |DapSessionRegistry| DAP_Client[Generic DAP Client Engine /src/dap] + DAP_Client <--> |stdin / stdout JSON-RPC| Adapter[Language Debug Adapter Subprocess] + + subgraph Debug Adapters tương thích + Adapter_Flutter[flutter debug_adapter] + Adapter_Rust[codelldb / lldb-vscode] + Adapter_Python[debugpy] + end + + Adapter -.-> Adapter_Flutter + Adapter -.-> Adapter_Rust + Adapter -.-> Adapter_Python +``` + +### Nguyên tắc thiết kế: +1. **Generic DAP Client (`src/dap`)**: Viết một client JSON-RPC chuẩn hóa, truyền thông qua stdin/stdout (hoặc TCP port) của tiến trình con. Client này độc lập hoàn toàn với ngôn ngữ lập trình. +2. **Standardized State (`DapSession`)**: Lưu trữ trạng thái gỡ lỗi chuẩn hóa: danh sách threads, call stack, variables scopes, active frame, và trạng thái suspend (paused/running). +3. **Language Configuration**: Mỗi ngôn ngữ sẽ định nghĩa một cấu hình Adapter trong file cấu hình (ví dụ: lệnh khởi chạy adapter, tham số truyền vào). + * **Flutter/Dart**: Chạy `flutter debug_adapter` hoặc `dart debug_adapter`. + * **Rust**: Chạy `codelldb` hoặc `lldb-vscode`. + * **Python**: Chạy `python -m debugpy --adapter`. + +--- + +## 2. Thiết Kế Module `/src/dap` + +Chúng ta tạo thư mục mới `/src/dap/` cấu trúc tương tự `/src/lsp/`: + +### A. `src/dap/types.rs` (DAP Protocol Definitions) +Định nghĩa các Struct serialize/deserialize theo đặc tả của Microsoft DAP: +* **Requests**: `InitializeRequest`, `LaunchRequest`, `SetBreakpointsRequest`, `StackTraceRequest`, `ScopesRequest`, `VariablesRequest`, `ContinueRequest`, `NextRequest` (Step Over), `StepInRequest`, `StepOutRequest`, `DisconnectRequest`. +* **Events**: `InitializedEvent`, `StoppedEvent` (lý do: breakpoint/step/exception), `ContinuedEvent`, `OutputEvent` (log output), `TerminatedEvent`. +* **Custom Requests cho Flutter**: Chuẩn DAP của Dart/Flutter hỗ trợ gửi các request tùy biến để reload/restart: + * Hot Reload: `{ "command": "customRequest", "arguments": { "request": "hotReload" } }` + * Hot Restart: `{ "command": "customRequest", "arguments": { "request": "hotRestart" } }` + +### B. `src/dap/client.rs` (DAP Process Handler) +* Quản lý vòng đời tiến trình con của Debug Adapter (ví dụ: `flutter debug_adapter`). +* Chạy một luồng đọc (stdout reader) chuyên biệt để nhận các gói JSON-RPC phản hồi hoặc Event bất đồng bộ từ adapter. +* Đánh số thứ tự gói tin gửi đi (`seq`) và map phản hồi trả về qua kênh `oneshot::Sender`. + +### C. `src/dap/session.rs` (DAP Session State) +* Quản lý trạng thái hiện tại của phiên debug: + * Isolate/Thread ID nào đang bị paused. + * Danh sách Stack Frames hiện tại. + * Ánh xạ Variables của scope đang xem (để vẽ lên cây phân cấp biến cục bộ). +* Gửi kết quả cập nhật về Event Loop của IDE để vẽ lại giao diện. + +--- + +## 3. Các Phím Tắt & Luồng Điều Hướng Tương Tác + +### A. Định nghĩa phím tắt trong `config/keymaps/default.toml` +```toml +# ── PHÍM TẮT ĐIỀU HƯỚNG PANEL ── +[[bindings]] +key = "ctrl+f" +command = "app.focus_explorer" # Focus vào File Explorer (Panel trái) + +[[bindings]] +key = "space d b" +command = "app.focus_dap" # Focus vào DAP Debugger Panel (Panel trái) + +# ── PHÍM TẮT DEBUG TOÀN CỤC ── +[[bindings]] +key = "F5" +command = "dap.continue" # Chạy debug / Tiếp tục chạy khi dừng + +[[bindings]] +key = "F6" +command = "dap.hot_restart" # Hot Restart (qua customRequest của DAP) + +[[bindings]] +key = "Shift+F5" +command = "dap.stop" # Dừng debug + +[[bindings]] +key = "F10" +command = "dap.step_over" # Đi qua (Next) + +[[bindings]] +key = "F11" +command = "dap.step_into" # Đi vào (Step In) + +[[bindings]] +key = "Shift+F11" +command = "dap.step_out" # Đi ra (Step Out) + +# ── TRONG PANEL DAP ── +[[bindings]] +mode = "normal" +key = "z a" +command = "dap.toggle_expand" # Collapse/Expand section hoặc dòng biến số đang chọn +``` + +--- + +## 4. Thiết Kế Chi Tiết Giao Diện DAP & Debug Console + +### A. Giao diện DAP (Panel Trái) +Khi Panel trái active tab `PanelTabId::Inspector` (DAP), giao diện sẽ hiển thị 4 vùng dọc, tự động tính toán kích thước khi người dùng nhấn `za`: + +1. **Variables (Biến số)**: + * Hiển thị danh sách các biến cục bộ thu được từ DAP request `scopes` -> `variables`. + * Hỗ trợ dạng cây phân cấp (nhấn `za` hoặc `Enter` để expand các thuộc tính sâu hơn của object nhờ `variableReference` của DAP). +2. **Watch (Theo dõi)**: + * Cho phép thêm biểu thức cần theo dõi. Khi suspend, DAP Client tự động gọi `evaluate` cho từng biểu thức và hiển thị kết quả. +3. **Call Stack (Ngăn xếp)**: + * Hiển thị danh sách Stack Frames thu được từ request `stackTrace`. + * Dòng suspended hiện tại sẽ có dấu chấm màu đỏ nổi bật `●`. Nhấn `Enter` vào một frame sẽ nhảy thẳng tới file và dòng code tương ứng trong editor. +4. **Breakpoints (Điểm dừng)**: + * Liệt kê tất cả các file và dòng đang đặt breakpoint. Cho phép bật/tắt nhanh breakpoint. + +**Thuật toán chia chiều cao động với `za`**: +* Chiều cao mặc định của Section Header khi collapse: $H_{collapsed} = 26.0\text{ px}$. +* Chiều cao khả dụng cho các section đang mở: + $$H_{expanded} = \frac{H_{total} - (N_{collapsed} \times H_{collapsed}) - \text{Gaps}}{N_{expanded}}$$ + +### B. Debug Console (Panel Dưới) +* Khi tiến trình Debug Adapter gửi các event `OutputEvent` (logs, print statements, exception stack trace), DAP Client sẽ chuyển hướng dữ liệu này tới tab **Debug Console** (`PanelTabId::DebugConsole`). +* Render văn bản tuần tự kèm màu sắc tiêu chuẩn (màu đỏ cho lỗi/stderr, màu trắng cho log thường, màu xanh cho thông tin debug). + +--- + +## 5. Lộ Trình Triển Khai Kỹ Thuật (Step-by-Step Implementation) + +### Giai đoạn 1: Xây dựng Module lõi `src/dap/` (Generic DAP Protocol) +1. Tạo thư mục `src/dap/` và định nghĩa các struct của chuẩn DAP trong `types.rs`. +2. Tạo `client.rs` để xử lý việc khởi chạy Adapter process dưới dạng `tokio::process::Child`, quản lý luồng ghi vào stdin và đọc từ stdout. +3. Tạo `session.rs` để quản lý luồng dữ liệu state (threads, frames, variables, breakpoints). + +### Giai đoạn 2: Tích hợp Giao diện DAP UI (Panel Trái) & Console (Panel Dưới) +1. Thêm nhãn `"DAP"` cho `PanelTabId::Inspector`. +2. Triển khai thuật toán tính chiều cao động trong `src/workbench/inspector_panel.rs` cho 4 section của DAP. +3. Liên kết phím tắt `za` để gọi hàm `toggle_expand()` của section hoặc của biến số đang chọn. +4. Tạo giao diện render log cho tab **Debug Console** tại Panel dưới. + +### Giai đoạn 3: Hiện thực hóa Flutter Debug Adapter +1. Triển khai lệnh chạy ngầm `flutter debug_adapter`. +2. Khi khởi chạy, gửi request `initialize`, sau đó gửi request `launch` kèm thông số cấu hình: + ```json + { + "program": "lib/main.dart", + "toolArgs": ["-d", "active_device_id"] + } + ``` +3. Lắng nghe `OutputEvent` của Dart VM gửi về và đẩy vào tab Debug Console. +4. Gửi customRequest `hotReload` khi lưu file bất kỳ, hoặc `hotRestart` khi nhấn `F6`. + +### Giai đoạn 4: Đồng bộ Breakpoint & Highlight Trực Quan trong Editor +1. Khi người dùng click hoặc dùng phím tắt toggle breakpoint trong editor, gọi `DapSession::set_breakpoints` để đồng bộ vị trí dòng sang adapter thông qua request `setBreakpoints`. +2. Khi nhận event `stopped` từ adapter, highlight dòng suspended màu vàng trong editor và hiển thị giá trị biến inline ở cuối dòng. +3. Khi nhận event `continued`, xóa highlight và con trỏ dừng ở gutter. diff --git a/src/app/app_state/editor.rs b/src/app/app_state/editor.rs index 32a0eb2a..362c34e7 100644 --- a/src/app/app_state/editor.rs +++ b/src/app/app_state/editor.rs @@ -839,8 +839,9 @@ impl AppState { let line_start = self.text.line_to_char(line); let line_end = self.line_content_end_char_idx(line); let dest = match kind { - FindMotionKind::ForwardTo => ((cursor + 1).min(line_end)..line_end) - .find(|&i| self.text.char(i) == target), + FindMotionKind::ForwardTo => { + ((cursor + 1).min(line_end)..line_end).find(|&i| self.text.char(i) == target) + } FindMotionKind::ForwardTill => ((cursor + 1).min(line_end)..line_end) .find(|&i| self.text.char(i) == target) .map(|hit| hit.saturating_sub(1)) diff --git a/src/app/app_state/mod.rs b/src/app/app_state/mod.rs index 9d2023f5..e35ca6cb 100644 --- a/src/app/app_state/mod.rs +++ b/src/app/app_state/mod.rs @@ -1226,7 +1226,9 @@ fn build_help_lines( "editor.center_cursor_line", "Center cursor", ); - lines.push(" f/F Find char on line (→/←), highlights all matches".to_string()); + lines.push( + " f/F Find char on line (→/←), highlights all matches".to_string(), + ); lines.push(" t/T Till char on line (→/←)".to_string()); lines.push(" n / N Repeat find-char or search jump (→/←)".to_string()); lines.push("".to_string()); @@ -2180,6 +2182,7 @@ pub struct AppState { // ── Workspace symbol cache ──────────────────────────────────────────────── /// Pre-indexed workspace symbols for fast import suggestions. workspace_symbol_cache: Arc, + active_dart_device_id: Option, } impl AppState { @@ -2242,9 +2245,18 @@ impl AppState { auto_folded_long_lines: Vec::new(), cached_line_starts: None, workspace_symbol_cache: Arc::new(crate::lsp::WorkspaceSymbolCache::new()), + active_dart_device_id: None, } } + pub fn active_dart_device_id(&self) -> Option<&str> { + self.active_dart_device_id.as_deref() + } + + pub fn set_active_dart_device_id(&mut self, device_id: Option) { + self.active_dart_device_id = device_id; + } + pub fn from_text(default_save_path: PathBuf, text: &str) -> Self { Self { text: Rope::from(text), @@ -2304,6 +2316,7 @@ impl AppState { auto_folded_long_lines: Vec::new(), cached_line_starts: None, workspace_symbol_cache: Arc::new(crate::lsp::WorkspaceSymbolCache::new()), + active_dart_device_id: None, } } diff --git a/src/app/app_state/palette.rs b/src/app/app_state/palette.rs index 0ddae726..39823822 100644 --- a/src/app/app_state/palette.rs +++ b/src/app/app_state/palette.rs @@ -66,6 +66,21 @@ impl AppState { .open_with_items(CommandPaletteMode::DartEnvSelector, items); } + pub fn open_flutter_device_selector(&mut self) -> bool { + let workspace = self.workspace_model.as_ref(); + self.command_palette + .open(CommandPaletteMode::FlutterDevices, workspace) + > 0 + } + + pub fn open_flutter_device_selector_with_items( + &mut self, + items: Vec, + ) { + self.command_palette + .open_with_items(CommandPaletteMode::FlutterDevices, items); + } + /// Push current file+line+column onto the jump back stack before a jump (e.g. gd). /// Clears the forward stack since jumping starts a new branch. pub fn push_jump(&mut self) { @@ -253,6 +268,9 @@ impl AppState { } pub fn command_palette_mode(&self) -> Option { + if self.command_palette.is_visible { + return Some(self.command_palette.mode); + } if let Some(index) = self.active_buffer_index { if let Some(BufferEntry { content: BufferContent::FuzzyPicker(state), @@ -262,11 +280,7 @@ impl AppState { return Some(state.mode); } } - if self.command_palette.is_visible { - Some(self.command_palette.mode) - } else { - None - } + None } pub fn command_palette_query_text(&self) -> &str { diff --git a/src/app/app_state/settings.rs b/src/app/app_state/settings.rs index 7d46cf2d..a663d6f6 100644 --- a/src/app/app_state/settings.rs +++ b/src/app/app_state/settings.rs @@ -14,28 +14,71 @@ pub struct AiInlineSettings { #[derive(Debug, Clone, PartialEq)] pub enum SettingItem { - ThemeSelector { current: String }, - FontFamily { current: String }, - FontSize { current: f32 }, - LineHeight { current: f32 }, - IndentTabWidth { current: u8 }, - IndentInsertSpaces { enabled: bool }, - InlineSuggestion { enabled: bool }, - AiApiUrl { current: String }, - AiModel { current: String }, - AiApiKey { current: String }, - AiEndpointKind { current: String }, - AiMaxTokens { current: u32 }, - AiPrefixChars { current: usize }, - AiSuffixChars { current: usize }, - AiDebounceMs { current: u64 }, - SidebarWidth { current: i32 }, - RightSidebarWidth { current: i32 }, - BottomPanelHeight { current: i32 }, - UiRounding { enabled: bool, radius_px: f32 }, - EnableOutline { enabled: bool }, + ThemeSelector { + current: String, + }, + FontFamily { + current: String, + }, + FontSize { + current: f32, + }, + LineHeight { + current: f32, + }, + IndentTabWidth { + current: u8, + }, + IndentInsertSpaces { + enabled: bool, + }, + InlineSuggestion { + enabled: bool, + }, + AiApiUrl { + current: String, + }, + AiModel { + current: String, + }, + AiApiKey { + current: String, + }, + AiEndpointKind { + current: String, + }, + AiMaxTokens { + current: u32, + }, + AiPrefixChars { + current: usize, + }, + AiSuffixChars { + current: usize, + }, + AiDebounceMs { + current: u64, + }, + SidebarWidth { + current: i32, + }, + RightSidebarWidth { + current: i32, + }, + BottomPanelHeight { + current: i32, + }, + UiRounding { + enabled: bool, + radius_px: f32, + }, + EnableOutline { + enabled: bool, + }, /// window.scale_factor_override — None = follow the display ("Auto"). - UiScale { current: Option }, + UiScale { + current: Option, + }, } impl SettingItem { @@ -259,9 +302,7 @@ impl SettingsState { .map(|v| format!("{v:.2}")) .unwrap_or_else(|| "auto".to_string()), ), - SettingItem::AiApiUrl { current } => { - (SettingsEditingKind::AiApiUrl, current.clone()) - } + SettingItem::AiApiUrl { current } => (SettingsEditingKind::AiApiUrl, current.clone()), SettingItem::AiModel { current } => (SettingsEditingKind::AiModel, current.clone()), SettingItem::AiApiKey { current } => (SettingsEditingKind::AiApiKey, current.clone()), SettingItem::AiEndpointKind { current } => { diff --git a/src/app/app_state/state.rs b/src/app/app_state/state.rs index 6b5772ff..5a305148 100644 --- a/src/app/app_state/state.rs +++ b/src/app/app_state/state.rs @@ -949,7 +949,9 @@ impl AppState { let total = self.text.len_chars(); let cursor = self.cursor_char_idx.min(total); let (line, _) = self.cursor_line_col(); - let line_start = self.text.line_to_char(line.min(self.text.len_lines().saturating_sub(1))); + let line_start = self + .text + .line_to_char(line.min(self.text.len_lines().saturating_sub(1))); let line_prefix = if line_start <= cursor { self.text.slice(line_start..cursor).to_string() } else { diff --git a/src/app/app_state/workspace.rs b/src/app/app_state/workspace.rs index ee542c13..52d1d661 100644 --- a/src/app/app_state/workspace.rs +++ b/src/app/app_state/workspace.rs @@ -8,6 +8,10 @@ impl AppState { Ok(()) } + pub fn detach_workspace(&mut self) { + self.workspace_model = None; + } + pub fn clear_workspace_session_state(&mut self) -> bool { let mut changed = false; diff --git a/src/app/command_palette.rs b/src/app/command_palette.rs index 3670ea0f..88774b5a 100644 --- a/src/app/command_palette.rs +++ b/src/app/command_palette.rs @@ -53,6 +53,10 @@ pub enum CommandPaletteMode { PythonEnvSelector, /// Dart environment selector — opened from the command palette. DartEnvSelector, + /// Flutter devices selector. + FlutterDevices, + /// Debug watch expression input. + DebugWatchInput, } impl CommandPaletteMode { @@ -79,6 +83,8 @@ impl CommandPaletteMode { Self::CodeAction => "action> ", Self::PythonEnvSelector => "python> ", Self::DartEnvSelector => "dart> ", + Self::FlutterDevices => "devices> ", + Self::DebugWatchInput => "watch> ", } } @@ -105,6 +111,8 @@ impl CommandPaletteMode { Self::CodeAction => "no code actions available", Self::PythonEnvSelector => "scanning Python environments...", Self::DartEnvSelector => "scanning Dart environments...", + Self::FlutterDevices => "scanning Flutter devices...", + Self::DebugWatchInput => "enter expression to watch...", } } @@ -131,6 +139,8 @@ impl CommandPaletteMode { Self::CodeAction => "ACTIONS", Self::PythonEnvSelector => "PYTHON ENV", Self::DartEnvSelector => "DART ENV", + Self::FlutterDevices => "DEVICES", + Self::DebugWatchInput => "WATCH", } } @@ -173,6 +183,11 @@ pub enum CommandPaletteAction { SelectPythonEnv(PathBuf), /// Chọn Dart/Flutter SDK path để restart LSP. SelectDartEnv(PathBuf), + SelectFlutterDevice { + device_id: String, + is_emulator: bool, + is_active: bool, + }, } #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] @@ -582,6 +597,7 @@ impl CommandPalette { | CommandPaletteMode::CodeAction | CommandPaletteMode::PythonEnvSelector | CommandPaletteMode::DartEnvSelector + | CommandPaletteMode::FlutterDevices ) { self.results = self.static_items.clone(); if self.results.is_empty() { @@ -654,6 +670,8 @@ impl CommandPalette { CommandPaletteMode::CodeAction => unreachable!("handled above"), CommandPaletteMode::PythonEnvSelector => Vec::new(), CommandPaletteMode::DartEnvSelector => Vec::new(), + CommandPaletteMode::FlutterDevices => Vec::new(), + CommandPaletteMode::DebugWatchInput => Vec::new(), }; if self.results.is_empty() { @@ -1100,6 +1118,17 @@ fn command_palette_items(query: &str, max_results: usize) -> Vec for AppShell { } return; } + if let Key::Named(NamedKey::F5) = key_event.logical_key { + eprintln!( + "[DAP LOG] [F5 Application] WindowEvent::KeyboardInput F5 pressed: {:?}", + key_event + ); + } let context = self.build_context(); let outcome = self.input_handler .translate_key_event(&key_event, &self.input_map, context); + if let Key::Named(NamedKey::F5) = key_event.logical_key { + eprintln!( + "[DAP LOG] [F5 Application] WindowEvent F5 translation outcome: {:?}", + outcome + ); + } match outcome { Some(InputRouteOutcome::Dispatch(translated)) => { + if let Command::DebugStart = &translated.command { + eprintln!( + "[DAP LOG] [F5 Application] WindowEvent F5 translated to Command::DebugStart" + ); + } if self .handle_command_with_count(translated.command, translated.repeat_count) { @@ -1252,12 +1269,40 @@ impl AppShell { syntax_spans_to_styled(&effective_highlights, &text, &self.theme); styled_spans .extend(diagnostic_spans_to_styled(&self.app_state, &self.theme)); + let breakpoint_lines = + if let Some(active_file) = self.app_state.active_file() { + self.breakpoints + .get(active_file) + .cloned() + .unwrap_or_default() + } else { + Vec::new() + }; renderer.update_editor_content( &text, &self.app_state, center_bounds, &styled_spans, + &breakpoint_lines, ); + let (dap_active, dap_paused, should_clear) = + if let Some(session) = &self.dap_session { + let state = session.state.try_lock(); + let terminated = + state.as_ref().map(|s| s.terminated).unwrap_or(false); + if terminated { + (false, false, true) + } else { + (true, state.map(|s| s.paused).unwrap_or(false), false) + } + } else { + (false, false, false) + }; + if should_clear { + self.dap_session = None; + } + renderer.dap_is_active = dap_active; + renderer.dap_is_paused = dap_paused; renderer.update_editor_overlays(&self.app_state, center_bounds); } } @@ -1361,6 +1406,12 @@ impl AppShell { cursor_col, &self.theme, )); + let breakpoint_lines = self + .app_state + .active_file() + .and_then(|active_file| self.breakpoints.get(active_file)) + .cloned() + .unwrap_or_default(); if breadcrumb_viewport_changed { let effective_highlights = crate::syntax::highlight::overlay_highlight_layers( @@ -1377,10 +1428,32 @@ impl AppShell { &self.app_state, center_bounds, &styled_spans, + &breakpoint_lines, ); } else { - renderer.update_editor_caret(&self.app_state, center_bounds); + renderer.update_editor_caret( + &self.app_state, + center_bounds, + &breakpoint_lines, + ); + } + let (dap_active, dap_paused, should_clear) = + if let Some(session) = &self.dap_session { + let state = session.state.try_lock(); + let terminated = state.as_ref().map(|s| s.terminated).unwrap_or(false); + if terminated { + (false, false, true) + } else { + (true, state.map(|s| s.paused).unwrap_or(false), false) + } + } else { + (false, false, false) + }; + if should_clear { + self.dap_session = None; } + renderer.dap_is_active = dap_active; + renderer.dap_is_paused = dap_paused; renderer.update_editor_overlays(&self.app_state, center_bounds); } self.editor_caret_needs_layout = false; @@ -1494,13 +1567,58 @@ impl AppShell { } else { 0 }; - let sidebar_rows = build_sidebar_rows( - &self.explorer_snapshot.entries, - self.explorer_cursor, - &self.theme, - self.app_state.workspace_has_active_filter(), - sidebar_scroll_offset_rows, - ); + let sidebar_rows = if self.panel_state.left.active_tab_id() == Some(PanelTabId::Inspector) { + if let Some(session) = &self.dap_session { + if let Ok(debug_state) = session.state.try_lock() { + self.dap_panel_state.sync_from_debug_state(&debug_state); + } + } + let visible = self.dap_panel_state.visible_rows(); + let selected = self.dap_panel_state.selected_row; + visible + .into_iter() + .enumerate() + .map(|(idx, r)| { + let arrow = self + .theme + .sidebar_arrow(r.expandable, r.expanded) + .to_string(); + let icon = if r.depth == 0 { + match r.section_index { + 0 => "󰏫", // Variables + 1 => "󰛢", // Watch + 2 => "󰢱", // CallStack + 3 => "󰔧", // Breakpoints + _ => "•", + } + .to_string() + } else { + "•".to_string() + }; + SidebarRow { + path: None, + depth: r.depth, + arrow, + nerd_icon: icon, + icon_color: self.theme.icons.default_file.color.as_f32(), + label: r.label, + prefix_marker: None, + prefix_color: None, + git_marker: None, + git_color: None, + is_selected: idx == selected, + } + }) + .collect::>() + } else { + build_sidebar_rows( + &self.explorer_snapshot.entries, + self.explorer_cursor, + &self.theme, + self.app_state.workspace_has_active_filter(), + sidebar_scroll_offset_rows, + ) + }; if let Some(renderer) = self.renderer.as_mut() { if let Some(bounds) = sidebar_bounds { let sidebar_focused = self.focus_manager.current() == FocusTarget::LeftSidebar; @@ -1838,6 +1956,7 @@ impl AppShell { self.runtime_versions.python_version.as_deref(), self.runtime_versions.node_version.as_deref(), self.runtime_versions.go_version.as_deref(), + self.app_state.active_dart_device_id(), status_bounds, ); region_instances.extend(pill_quads); @@ -1886,18 +2005,56 @@ impl AppShell { .map(|renderer| { renderer.terminal_tab_bar_content_bounds( bottom_bounds, - self.terminal_tabs.len(), + self.panel_state.bottom.tabs.len(), ) }) .unwrap_or(bottom_bounds); + let active_tab = self.panel_state.bottom.active_tab_id(); + let is_debug_console = active_tab == Some(PanelTabId::DebugConsole); + if is_debug_console { + let messages = if let Some(session) = &self.dap_session { + if let Ok(state) = session.state.try_lock() { + state.console_messages.clone() + } else { + Vec::new() + } + } else { + Vec::new() + }; + + if messages.len() < self.dap_console_fed_count { + self.dap_console_grid.clear(); + self.dap_console_fed_count = 0; + } + + if self.dap_console_fed_count < messages.len() { + for msg in &messages[self.dap_console_fed_count..] { + self.dap_console_grid.feed_chunk(msg); + if !msg.ends_with('\n') { + self.dap_console_grid.feed_chunk("\n"); + } + } + self.dap_console_fed_count = messages.len(); + self.dap_console_grid.view_scroll_to_bottom(); + } + } + let bounds_changed = self.last_terminal_bounds != Some(bottom_bounds); let grid_changed = self.sync_terminal_layout(terminal_content_bounds); - if (self.terminal_needs_layout || bounds_changed || grid_changed) + if (self.terminal_needs_layout + || bounds_changed + || grid_changed + || is_debug_console) && let Some(renderer) = self.renderer.as_mut() { + let grid = if is_debug_console { + &self.dap_console_grid + } else { + &self.terminal_tabs[self.active_terminal_tab].grid + }; renderer.update_terminal_content( - &self.terminal_tabs[self.active_terminal_tab].grid, + grid, terminal_content_bounds, self.app_state.current_mode(), ); @@ -1907,22 +2064,42 @@ impl AppShell { // Render tab bar after terminal content layout so tab labels are // appended after body glyphs in the shared terminal text pipeline. + // Compute tab info before mutable borrow of renderer. + let tab_labels_short: Vec<&str> = self + .panel_state + .bottom + .tabs + .iter() + .map(|t| match t { + PanelTabId::Terminal => "Term", + PanelTabId::DebugConsole => "Debug", + PanelTabId::Problems => "Probs", + _ => t.label(), + }) + .collect(); + let terminal_is_running = self + .active_terminal_tab() + .is_some_and(|tab| tab.status.is_running()); + let dap_is_active = self.dap_session.is_some(); + let tab_running: Vec = self + .panel_state + .bottom + .tabs + .iter() + .map(|t| match t { + PanelTabId::Terminal => terminal_is_running, + PanelTabId::DebugConsole => dap_is_active, + _ => false, + }) + .collect(); + let active_tab_idx = self.panel_state.bottom.active_tab; + let tab_bar_quads = if let Some(renderer) = self.renderer.as_mut() { - let labels: Vec<&str> = self - .terminal_tabs - .iter() - .map(|t| t.label.as_str()) - .collect(); - let running: Vec = self - .terminal_tabs - .iter() - .map(|t| t.status.is_running()) - .collect(); renderer .update_terminal_tab_bar( - &labels, - &running, - self.active_terminal_tab, + &tab_labels_short, + &tab_running, + active_tab_idx, bottom_bounds, ) .0 @@ -2109,8 +2286,8 @@ mod tests { use super::{ breadcrumb_segment_text, build_editor_breadcrumb_segments, focus_ring_instances, focus_target_region_id, mouse_button_code, point_in_bounds, sgr_mouse_sequence, - sgr_wheel_sequence, - statusbar_source_path_label, terminal_cell_at_position, visible_region_bounds, + sgr_wheel_sequence, statusbar_source_path_label, terminal_cell_at_position, + visible_region_bounds, }; use crate::async_runtime::message::{ LspDocumentSymbol, LspDocumentSymbolSegment, LspPosition, LspRange, diff --git a/src/app/event_loop/async_results/ai.rs b/src/app/event_loop/async_results/ai.rs index 96581dba..b439a2a0 100644 --- a/src/app/event_loop/async_results/ai.rs +++ b/src/app/event_loop/async_results/ai.rs @@ -167,21 +167,13 @@ mod tests { // Caret on a fresh line indented by 8 spaces; the model re-emits that // indentation. Without stripping it the accepted line is double-indented. assert_eq!( - sanitize_inline_suggestion( - " results.append(item * 2)", - " ", - "", - ), + sanitize_inline_suggestion(" results.append(item * 2)", " ", "",), Some("results.append(item * 2)".to_string()) ); // Tab indentation, multi-line: line 1's echoed tab is dropped, deeper // lines keep their own absolute indentation. assert_eq!( - sanitize_inline_suggestion( - "\tfor i := range xs {\n\t\tsum += i\n\t}", - "\t", - "", - ), + sanitize_inline_suggestion("\tfor i := range xs {\n\t\tsum += i\n\t}", "\t", "",), Some("for i := range xs {\n\t\tsum += i\n\t}".to_string()) ); } diff --git a/src/app/event_loop/async_results/mod.rs b/src/app/event_loop/async_results/mod.rs index 14d5cf84..c9dd895c 100644 --- a/src/app/event_loop/async_results/mod.rs +++ b/src/app/event_loop/async_results/mod.rs @@ -94,7 +94,9 @@ impl AsyncResultRouter for AppShell { WorkerResultPayload::SystemDepCheckResult { .. } | WorkerResultPayload::RuntimeVersionsDetected { .. } | WorkerResultPayload::PythonEnvironmentsDiscovered(_) - | WorkerResultPayload::DartEnvironmentsDiscovered(_) => { + | WorkerResultPayload::DartEnvironmentsDiscovered(_) + | WorkerResultPayload::FlutterDevicesDiscovered(_) + | WorkerResultPayload::FlutterEmulatorLaunched => { system::handle_system_result(self, result.payload); } WorkerResultPayload::FileCopyResult { .. } => { diff --git a/src/app/event_loop/async_results/system.rs b/src/app/event_loop/async_results/system.rs index 73016e85..a2ac3394 100644 --- a/src/app/event_loop/async_results/system.rs +++ b/src/app/event_loop/async_results/system.rs @@ -98,6 +98,44 @@ pub(super) fn handle_system_result(app: &mut AppShell, payload: WorkerResultPayl app.app_state.open_dart_env_selector_with_items(items); app.request_redraw(); } + WorkerResultPayload::FlutterDevicesDiscovered(devices) => { + eprintln!("[AppShell] flutter devices discovered: {}", devices.len()); + let items: Vec = devices + .into_iter() + .map(|dev| { + let secondary = if dev.is_active { + Some(format!("active ({})", dev.platform)) + } else { + Some(format!("emulator offline ({})", dev.platform)) + }; + crate::app::command_palette::CommandPaletteItem { + label: dev.name, + secondary_label: secondary, + action: + crate::app::command_palette::CommandPaletteAction::SelectFlutterDevice { + device_id: dev.id, + is_emulator: dev.emulator, + is_active: dev.is_active, + }, + tone: crate::app::command_palette::CommandPaletteItemTone::Default, + preview_colors: Vec::new(), + } + }) + .collect(); + app.app_state.open_flutter_device_selector_with_items(items); + app.request_redraw(); + } + WorkerResultPayload::FlutterEmulatorLaunched => { + app.show_transient_toast("Flutter emulator launched successfully.".to_string()); + let flutter_path = app.current_flutter_path(); + app.submit(crate::async_runtime::message::RequestSpec { + revision_id: 0, + topic: crate::async_runtime::message::RequestTopic::SystemTask, + payload: crate::async_runtime::message::WorkerRequestPayload::ScanFlutterDevices { + flutter_path: Some(flutter_path), + }, + }); + } _ => {} } } diff --git a/src/app/event_loop/commands.rs b/src/app/event_loop/commands.rs index 5fe98dfc..a4f8dd50 100644 --- a/src/app/event_loop/commands.rs +++ b/src/app/event_loop/commands.rs @@ -2,6 +2,8 @@ mod commands_ai_chat; #[path = "commands_completion.rs"] mod commands_completion; +#[path = "commands_dap.rs"] +mod commands_dap; #[path = "commands_editor.rs"] mod commands_editor; #[path = "commands_explorer.rs"] @@ -100,6 +102,12 @@ impl AppShell { Command::SaveFile => { self.submit_workspace_git_status_refresh(); self.submit_active_buffer_git_baseline_refresh(); + if let Some(session) = &self.dap_session { + let session_clone = session.clone(); + tokio::spawn(async move { + let _ = session_clone.hot_reload().await; + }); + } } _ => {} } @@ -282,9 +290,9 @@ impl AppShell { self.app_state .retain_inline_suggestion_for_typed_text(ch.encode_utf8(&mut buf)) } - Command::InsertText(text) => self - .app_state - .retain_inline_suggestion_for_typed_text(text), + Command::InsertText(text) => { + self.app_state.retain_inline_suggestion_for_typed_text(text) + } _ => false, }; if retained { @@ -356,6 +364,24 @@ impl AppShell { changed, ); } + if let Command::DebugStart = &command { + eprintln!( + "[DAP LOG] [F5 Commands] handle_command_with_count: Routing Command::DebugStart to handle_dap_command" + ); + } + if let Some(changed) = self.handle_dap_command(&command) { + if let Command::DebugStart = &command { + eprintln!( + "[DAP LOG] [F5 Commands] handle_command_with_count: handle_dap_command returned: {:?}", + changed + ); + } + return self.finalize_post_command_hooks( + &command_for_post_hooks, + should_persist_history_after, + changed, + ); + } if let Some(changed) = self.handle_palette_and_open_command(&command, repeat_count, &command_for_post_hooks) { diff --git a/src/app/event_loop/commands_dap.rs b/src/app/event_loop/commands_dap.rs new file mode 100644 index 00000000..037e44f3 --- /dev/null +++ b/src/app/event_loop/commands_dap.rs @@ -0,0 +1,943 @@ +use super::*; +use crate::core::commands::Command; +use crate::workbench::focus_manager::FocusTarget; +use crate::workbench::panel_state::PanelTabId; +use std::path::{Path, PathBuf}; +use std::sync::Arc; + +fn resolve_flutter_path(dart_path: &Path) -> PathBuf { + let dart_str = dart_path.to_string_lossy(); + if dart_str.contains("bin/cache/dart-sdk/bin/dart") { + let flutter_str = dart_str.replace("bin/cache/dart-sdk/bin/dart", "bin/flutter"); + let path = PathBuf::from(flutter_str); + if path.exists() { + return path; + } + } + if let Some(parent) = dart_path.parent() { + let flutter_same_dir = parent.join("flutter"); + if flutter_same_dir.exists() { + return flutter_same_dir; + } + if let Some(grandparent) = parent.parent() { + let flutter_grand = grandparent.join("flutter"); + if flutter_grand.exists() { + return flutter_grand; + } + let flutter_grand_bin = grandparent.join("bin").join("flutter"); + if flutter_grand_bin.exists() { + return flutter_grand_bin; + } + } + } + PathBuf::from("flutter") +} + +impl AppShell { + pub fn handle_dap_command(&mut self, command: &Command) -> Option { + match command { + Command::FocusDap => { + let mut changed = self.release_focus_mode_to_editor(); + if !self.panel_state.left.visible { + self.panel_state.left.visible = true; + changed = true; + self.sidebar_needs_layout = true; + } + if self.panel_state.left.active_tab_id() != Some(PanelTabId::Inspector) { + self.panel_state.left.switch_to_tab(PanelTabId::Inspector); + changed = true; + self.sidebar_needs_layout = true; + } + changed |= self.dismiss_initial_launch_welcome_if_active(); + let focus_changed = self.focus_manager.set(FocusTarget::LeftSidebar); + changed |= focus_changed; + if focus_changed { + self.input_handler.clear_pending_prefix(); + } + Some(changed) + } + Command::DapToggleExpand => { + if self.focus_manager.current() == FocusTarget::LeftSidebar + && self.panel_state.left.active_tab_id() == Some(PanelTabId::Inspector) + { + let changed = self.dap_panel_state.toggle_selected_expand(); + if changed { + self.sidebar_needs_layout = true; + } + Some(changed) + } else { + None + } + } + Command::DebugStart => { + eprintln!( + "[DAP LOG] [F5 Commands DAP] handle_dap_command: entered Command::DebugStart" + ); + let mut should_clear = false; + if let Some(session) = &self.dap_session { + let (paused, terminated) = { + let state = session.state.try_lock(); + state + .map(|s| (s.paused, s.terminated)) + .unwrap_or((false, false)) + }; + eprintln!( + "[DAP LOG] [F5 Commands DAP] DebugStart with existing session, paused={}, terminated={}", + paused, terminated + ); + if terminated { + should_clear = true; + } else { + if paused { + let session_clone = session.clone(); + let window_clone = self.window.clone(); + let _guard = self.scheduler.enter(); + tokio::spawn(async move { + let _ = session_clone.resume().await; + if let Some(w) = window_clone { + w.request_redraw(); + } + }); + return Some(true); + } + return Some(false); + } + } + if should_clear { + self.dap_session = None; + } + + // Launch new session — try launch.json first, then fallback to Flutter defaults + let workspace_root = self + .app_state + .workspace_root_path() + .map(PathBuf::from) + .or_else(|| { + self.app_state + .active_file() + .and_then(|file| file.parent().map(PathBuf::from)) + }) + .unwrap_or_default(); + eprintln!( + "[DAP LOG] [F5 Commands DAP] workspace_root: {:?}", + workspace_root + ); + + if workspace_root.as_os_str().is_empty() { + eprintln!( + "[DAP LOG] [F5 Commands DAP] No workspace_root found, returning false" + ); + self.show_transient_toast_kind( + "No active workspace or file parent folder", + ToastKind::Error, + ); + return Some(false); + } + + self.show_transient_toast("Starting debugger...".to_string()); + + // Try to load launch.json (.vscode/launch.json or .zed/debug.json) + let launch_config = crate::dap::launch_config::load_launch_json(&workspace_root) + .and_then(|cfg| { + eprintln!( + "[DebugStart] Found launch.json with {} configurations", + cfg.configurations.len() + ); + cfg.configurations.into_iter().next() + }); + + if launch_config.is_none() { + eprintln!("[DebugStart] No launch.json found, using Flutter defaults"); + } + + let (program, adapter_cmd, adapter_args, adapter_id) = if let Some(ref config) = + launch_config + { + let resolved = config.resolve(&workspace_root); + let program = if resolved.program.is_empty() { + workspace_root + .join("lib") + .join("main.dart") + .to_string_lossy() + .to_string() + } else { + resolved.program.clone() + }; + match config.config_type.as_str() { + "dart" | "flutter" => { + // Check if FVM is available - prefer fvm flutter over direct flutter + let fvm_path = Self::resolve_fvm_path(); + let has_local_fvm = + workspace_root.join(".fvm").join("flutter_sdk").exists(); + + let (adapter_cmd, adapter_args) = if has_local_fvm { + // Use FVM wrapper for local FVM projects + if let Some(ref fvm) = fvm_path { + eprintln!("[DebugStart] Using FVM wrapper: {:?}", fvm); + ( + fvm.to_string_lossy().to_string(), + vec!["flutter".to_string(), "debug_adapter".to_string()], + ) + } else { + // FVM not found in PATH, fallback to direct flutter + let dart_bin = self.selected_dart_env.clone().or_else(|| { + let local_fvm = workspace_root + .join(".fvm") + .join("flutter_sdk") + .join("bin") + .join("cache") + .join("dart-sdk") + .join("bin") + .join("dart"); + if local_fvm.try_exists().unwrap_or(false) { + Some(local_fvm) + } else { + None + } + }); + let flutter_path = dart_bin + .as_ref() + .map(|d| resolve_flutter_path(d)) + .unwrap_or_else(|| PathBuf::from("flutter")); + ( + flutter_path.to_string_lossy().to_string(), + vec!["debug_adapter".to_string()], + ) + } + } else { + // No local FVM, use direct flutter + let dart_bin = self.selected_dart_env.clone().or_else(|| { + let local_fvm = workspace_root + .join(".fvm") + .join("flutter_sdk") + .join("bin") + .join("cache") + .join("dart-sdk") + .join("bin") + .join("dart"); + if local_fvm.try_exists().unwrap_or(false) { + Some(local_fvm) + } else { + None + } + }); + let flutter_path = dart_bin + .as_ref() + .map(|d| resolve_flutter_path(d)) + .unwrap_or_else(|| PathBuf::from("flutter")); + ( + flutter_path.to_string_lossy().to_string(), + vec!["debug_adapter".to_string()], + ) + }; + + (program, adapter_cmd, adapter_args, "flutter".to_string()) + } + "lldb" | "codelldb" => { + (program, "codelldb".to_string(), vec![], "lldb".to_string()) + } + "python" | "debugpy" => ( + program, + "python".to_string(), + vec![ + "-m".to_string(), + "debugpy".to_string(), + "--adapter".to_string(), + ], + "debugpy".to_string(), + ), + _ => { + // Generic adapter — use config type as adapter ID + ( + program, + config.config_type.clone(), + vec![], + config.config_type.clone(), + ) + } + } + } else { + // No launch.json — fallback to Flutter defaults with FVM support + let fvm_path = Self::resolve_fvm_path(); + let has_local_fvm = workspace_root.join(".fvm").join("flutter_sdk").exists(); + + let (adapter_cmd, adapter_args) = if has_local_fvm { + // Use FVM wrapper for local FVM projects + if let Some(ref fvm) = fvm_path { + eprintln!("[DebugStart] Using FVM wrapper (no launch.json): {:?}", fvm); + ( + fvm.to_string_lossy().to_string(), + vec!["flutter".to_string(), "debug_adapter".to_string()], + ) + } else { + // FVM not found in PATH, fallback to direct flutter + let dart_bin = self.selected_dart_env.clone().or_else(|| { + let local_fvm = workspace_root + .join(".fvm") + .join("flutter_sdk") + .join("bin") + .join("cache") + .join("dart-sdk") + .join("bin") + .join("dart"); + if local_fvm.try_exists().unwrap_or(false) { + Some(local_fvm) + } else { + None + } + }); + let flutter_path = dart_bin + .as_ref() + .map(|d| resolve_flutter_path(d)) + .unwrap_or_else(|| PathBuf::from("flutter")); + ( + flutter_path.to_string_lossy().to_string(), + vec!["debug_adapter".to_string()], + ) + } + } else { + // No local FVM, use direct flutter + let dart_bin = self.selected_dart_env.clone().or_else(|| { + let local_fvm = workspace_root + .join(".fvm") + .join("flutter_sdk") + .join("bin") + .join("cache") + .join("dart-sdk") + .join("bin") + .join("dart"); + if local_fvm.try_exists().unwrap_or(false) { + Some(local_fvm) + } else { + None + } + }); + let flutter_path = dart_bin + .as_ref() + .map(|d| resolve_flutter_path(d)) + .unwrap_or_else(|| PathBuf::from("flutter")); + ( + flutter_path.to_string_lossy().to_string(), + vec!["debug_adapter".to_string()], + ) + }; + + let program = self + .app_state + .active_file() + .and_then(|id| { + if id.extension().map(|ext| ext == "dart").unwrap_or(false) { + Some(id.to_string_lossy().to_string()) + } else { + None + } + }) + .unwrap_or_else(|| { + let main_dart = workspace_root.join("lib").join("main.dart"); + if main_dart.exists() { + main_dart.to_string_lossy().to_string() + } else { + "lib/main.dart".to_string() + } + }); + (program, adapter_cmd, adapter_args, "flutter".to_string()) + }; + + eprintln!( + "[DebugStart] adapter_cmd={}, adapter_args={:?}, adapter_id={}, program={}", + adapter_cmd, adapter_args, adapter_id, program + ); + + // Build FVM environment variables if using FVM + let fvm_env = if adapter_args.len() >= 2 + && adapter_args[0] == "flutter" + && adapter_args[1] == "debug_adapter" + { + // Using FVM wrapper - set FLUTTER_ROOT to the local SDK + let flutter_sdk = workspace_root.join(".fvm").join("flutter_sdk"); + if flutter_sdk.exists() { + let mut env = std::collections::HashMap::new(); + env.insert( + "FLUTTER_ROOT".to_string(), + flutter_sdk.to_string_lossy().to_string(), + ); + // Also add flutter/bin to PATH + let flutter_bin = flutter_sdk.join("bin"); + if let Ok(path) = std::env::var("PATH") { + let new_path = format!("{}:{}", flutter_bin.display(), path); + env.insert("PATH".to_string(), new_path); + } + eprintln!( + "[DebugStart] Setting FVM env: FLUTTER_ROOT={}", + flutter_sdk.display() + ); + Some(env) + } else { + None + } + } else { + None + }; + + let (event_tx, mut event_rx) = tokio::sync::mpsc::unbounded_channel(); + let _guard = self.scheduler.enter(); + match crate::dap::client::DapClient::launch_with_env( + &adapter_cmd, + &adapter_args, + Some(workspace_root.clone()), + event_tx, + fvm_env, + ) { + Ok(client) => { + let session = Arc::new(crate::dap::DapSession::new(client)); + self.dap_session = Some(session.clone()); + + // Open Debug Console tab in bottom dock and focus it + self.panel_state.bottom.visible = true; + self.panel_state + .bottom + .switch_to_tab(PanelTabId::DebugConsole); + + // Open Left Sidebar and switch to DAP (Inspector) + if !self.panel_state.left.visible { + self.panel_state.left.visible = true; + } + if self.panel_state.left.active_tab_id() != Some(PanelTabId::Inspector) { + self.panel_state.left.switch_to_tab(PanelTabId::Inspector); + } + + self.sidebar_needs_layout = true; + + // Build launch arguments from config or defaults + let launch_args = if let Some(ref config) = launch_config { + let resolved = config.resolve(&workspace_root); + let program_to_use = if resolved.program.is_empty() { + program.clone() + } else { + resolved.program.clone() + }; + let mut args = serde_json::json!({ + "request": "launch", + "program": program_to_use, + "noDebug": false, + }); + if !resolved.args.is_empty() { + args["args"] = serde_json::json!(resolved.args); + } + if !resolved.tool_args.is_empty() { + args["toolArgs"] = serde_json::json!(resolved.tool_args); + } + if let Some(device_id) = &resolved.device_id { + args["deviceId"] = serde_json::json!(device_id); + } + if let Some(cwd) = &resolved.cwd { + args["cwd"] = serde_json::json!(cwd.to_string_lossy()); + } + if !resolved.env.is_empty() { + args["env"] = serde_json::json!(resolved.env); + } + // Merge custom/extra fields from the launch configuration to be fully compatible with VS Code + if let Some(obj) = args.as_object_mut() { + for (k, v) in &config.extra { + if let Some(k_str) = k.as_str() { + obj.insert(k_str.to_string(), v.clone()); + } + } + } + eprintln!("[DebugStart] Using launch.json config: {:?}", config.name); + args + } else { + let device_id = self + .app_state + .active_dart_device_id() + .unwrap_or("chrome") + .to_string(); + eprintln!( + "[DebugStart] Using Flutter defaults: program={}, device={}", + program, device_id + ); + serde_json::json!({ + "request": "launch", + "program": program, + "toolArgs": ["-d", device_id], + "noDebug": false, + }) + }; + eprintln!("[DebugStart] launch_args: {}", launch_args); + + let adapter_id_clone = adapter_id.clone(); + let session_clone = session.clone(); + let window_clone = self.window.clone(); + tokio::spawn(async move { + if let Err(e) = session_clone.initialize(&adapter_id_clone).await { + eprintln!("DAP Init error: {}", e); + return; + } + if let Err(e) = session_clone + .client + .send_request("launch", Some(launch_args)) + .await + { + eprintln!("DAP Launch error: {}", e); + return; + } + let _ = session_clone + .client + .send_request("configurationDone", None) + .await; + { + let mut state = session_clone.state.lock().await; + state.paused = false; + state + .console_messages + .push("Debug session started.".to_string()); + } + if let Some(w) = &window_clone { + w.request_redraw(); + } + }); + + // Sync existing breakpoints to session + let grouped_bps = self.breakpoints.clone(); + let session_bps_clone = session.clone(); + let _guard = self.scheduler.enter(); + tokio::spawn(async move { + for (path, lines) in grouped_bps { + let _ = session_bps_clone.set_breakpoints(&path, &lines).await; + } + }); + + let session_event_clone = session.clone(); + let window_event_clone = self.window.clone(); + tokio::spawn(async move { + while let Some(event) = event_rx.recv().await { + match event.event.as_str() { + "stopped" => { + let thread_id = event + .body + .as_ref() + .and_then(|b| b.get("threadId")) + .and_then(|t| t.as_i64()) + .unwrap_or(1); + let _ = session_event_clone + .update_suspended_state(thread_id) + .await; + let _ = + session_event_clone.evaluate_watch_expressions().await; + } + "continued" => { + let mut state_guard = + session_event_clone.state.lock().await; + state_guard.paused = false; + state_guard.execution_location = None; + } + "output" => { + if let Some(body) = event.body { + if let Some(output) = + body.get("output").and_then(|v| v.as_str()) + { + let mut state_guard = + session_event_clone.state.lock().await; + state_guard + .console_messages + .push(output.to_string()); + } + } + } + "terminated" => { + let mut state_guard = + session_event_clone.state.lock().await; + state_guard.terminated = true; + state_guard.paused = true; + state_guard.execution_location = None; + break; + } + _ => {} + } + if let Some(w) = &window_event_clone { + w.request_redraw(); + } + } + }); + Some(true) + } + Err(err) => { + eprintln!("Failed to launch debug adapter: {}", err); + self.show_transient_toast_kind( + format!("Failed to launch debug adapter: {}", err), + ToastKind::Error, + ); + Some(false) + } + } + } + Command::DebugStop => { + if let Some(session) = &self.dap_session { + let session_clone = session.clone(); + let window_clone = self.window.clone(); + let _guard = self.scheduler.enter(); + tokio::spawn(async move { + let _ = session_clone.stop().await; + if let Some(w) = window_clone { + w.request_redraw(); + } + }); + self.dap_session = None; + Some(true) + } else { + None + } + } + Command::DebugContinue => { + if let Some(session) = &self.dap_session { + let session_clone = session.clone(); + let window_clone = self.window.clone(); + let _guard = self.scheduler.enter(); + tokio::spawn(async move { + let _ = session_clone.resume().await; + if let Some(w) = window_clone { + w.request_redraw(); + } + }); + Some(true) + } else { + None + } + } + Command::DebugStepOver => { + if let Some(session) = &self.dap_session { + let session_clone = session.clone(); + let window_clone = self.window.clone(); + let _guard = self.scheduler.enter(); + tokio::spawn(async move { + let _ = session_clone.step_over().await; + if let Some(w) = window_clone { + w.request_redraw(); + } + }); + Some(true) + } else { + None + } + } + Command::DebugStepInto => { + if let Some(session) = &self.dap_session { + let session_clone = session.clone(); + let window_clone = self.window.clone(); + let _guard = self.scheduler.enter(); + tokio::spawn(async move { + let _ = session_clone.step_into().await; + if let Some(w) = window_clone { + w.request_redraw(); + } + }); + Some(true) + } else { + None + } + } + Command::DebugStepOut => { + if let Some(session) = &self.dap_session { + let session_clone = session.clone(); + let window_clone = self.window.clone(); + let _guard = self.scheduler.enter(); + tokio::spawn(async move { + let _ = session_clone.step_out().await; + if let Some(w) = window_clone { + w.request_redraw(); + } + }); + Some(true) + } else { + None + } + } + Command::DebugToggleBreakpoint => { + if let Some(active_file) = self.app_state.active_file() { + let (line, _) = self.app_state.cursor_line_col(); + let path = active_file + .canonicalize() + .unwrap_or_else(|_| active_file.to_path_buf()); + // Toggle in our local breakpoints map + let is_empty = { + let lines = self.breakpoints.entry(path.clone()).or_default(); + if let Some(pos) = lines.iter().position(|&l| l == line) { + lines.remove(pos); + } else { + lines.push(line); + } + lines.is_empty() + }; + if is_empty { + self.breakpoints.remove(&path); + } + let lines_clone = self.breakpoints.get(&path).cloned().unwrap_or_default(); + self.save_breakpoints(); + + if let Some(session) = &self.dap_session { + if let Ok(mut state_guard) = session.state.try_lock() { + state_guard.toggle_breakpoint_at_line(&path, line); + } + let session_clone = session.clone(); + let _guard = self.scheduler.enter(); + tokio::spawn(async move { + let _ = session_clone.set_breakpoints(&path, &lines_clone).await; + }); + } + self.sidebar_needs_layout = true; + self.editor_needs_layout = true; + self.editor_caret_needs_layout = true; + if let Some(w) = &self.window { + w.request_redraw(); + } + Some(true) + } else { + None + } + } + Command::FlutterHotReload => { + if let Some(session) = &self.dap_session { + let session_clone = session.clone(); + let _guard = self.scheduler.enter(); + tokio::spawn(async move { + let _ = session_clone.hot_reload().await; + }); + Some(true) + } else { + None + } + } + Command::FlutterHotRestart => { + if let Some(session) = &self.dap_session { + let session_clone = session.clone(); + let _guard = self.scheduler.enter(); + tokio::spawn(async move { + let _ = session_clone.hot_restart().await; + }); + Some(true) + } else { + None + } + } + Command::DebugWatchAdd => { + if self.dap_session.is_some() { + let _ = self.app_state.open_command_palette_mode( + crate::app::command_palette::CommandPaletteMode::DebugWatchInput, + ); + Some(true) + } else { + None + } + } + Command::DebugWatchRemove => { + if let Some(session) = &self.dap_session { + let rows = self.dap_panel_state.visible_rows(); + if let Some(row) = rows.get(self.dap_panel_state.selected_row) { + if row.section_index == 1 { + // Watch section + if let Some(path) = &row.node_path { + if let Some(node_index) = path.first() { + if let Ok(mut state) = session.state.try_lock() { + if state.remove_watch_expression(*node_index) { + drop(state); + if let Ok(state_guard) = session.state.try_lock() { + self.dap_panel_state + .sync_from_debug_state(&state_guard); + } + self.sidebar_needs_layout = true; + return Some(true); + } + } + } + } + } + } + Some(false) + } else { + None + } + } + Command::DebugGotoFrame => { + if let Some(session) = &self.dap_session { + let rows = self.dap_panel_state.visible_rows(); + if let Some(row) = rows.get(self.dap_panel_state.selected_row) { + if row.section_index == 2 { + // Call Stack section + if let Some(path) = &row.node_path { + if let Some(node_index) = path.first() { + if let Ok(state) = session.state.try_lock() { + if let Some(frame) = state.call_stack.get(*node_index) { + let file_path = frame.path.clone(); + let line = frame.location.line; + drop(state); + if !file_path.as_os_str().is_empty() { + let _ = self.app_state.open_file(file_path); + self.app_state.jump_to_line(line); + return Some(true); + } + } + } + } + } + } + } + Some(false) + } else { + None + } + } + Command::FlutterDevices => { + self.app_state.open_flutter_device_selector(); + let flutter_path = self.current_flutter_path(); + self.submit(crate::async_runtime::message::RequestSpec { + revision_id: 0, + topic: crate::async_runtime::message::RequestTopic::SystemTask, + payload: + crate::async_runtime::message::WorkerRequestPayload::ScanFlutterDevices { + flutter_path: Some(flutter_path), + }, + }); + Some(true) + } + _ => None, + } + } + + pub(super) fn confirm_flutter_device_selection(&mut self) -> bool { + use crate::app::command_palette::CommandPaletteAction; + if let Some(CommandPaletteAction::SelectFlutterDevice { + device_id, + is_emulator, + is_active, + }) = self.app_state.command_palette_selected_action() + { + self.app_state + .set_active_dart_device_id(Some(device_id.clone())); + if is_emulator && !is_active { + let flutter_path = self.current_flutter_path(); + self.show_transient_toast(format!("Launching emulator {}...", device_id)); + self.submit(crate::async_runtime::message::RequestSpec { + revision_id: 0, + topic: crate::async_runtime::message::RequestTopic::SystemTask, + payload: + crate::async_runtime::message::WorkerRequestPayload::LaunchFlutterEmulator { + flutter_path: Some(flutter_path), + emulator_id: device_id.clone(), + }, + }); + } + true + } else { + false + } + } + + fn resolve_fvm_path() -> Option { + if let Ok(home) = std::env::var("HOME") { + for relative in &["fvm/bin/fvm", ".fvm/bin/fvm", ".pub-cache/bin/fvm"] { + let p = PathBuf::from(&home).join(relative); + if p.exists() { + return Some(p); + } + } + } + for p in &[ + "/opt/homebrew/bin/fvm", + "/usr/local/bin/fvm", + "/usr/bin/fvm", + ] { + let p_path = PathBuf::from(p); + if p_path.exists() { + return Some(p_path); + } + } + None + } + + pub fn current_flutter_path(&self) -> PathBuf { + let workspace_root = self + .app_state + .workspace_root_path() + .map(PathBuf::from) + .unwrap_or_default(); + let dart_bin = self.selected_dart_env.clone().or_else(|| { + let local_fvm = workspace_root + .join(".fvm") + .join("flutter_sdk") + .join("bin") + .join("cache") + .join("dart-sdk") + .join("bin") + .join("dart"); + if local_fvm.try_exists().unwrap_or(false) { + Some(local_fvm) + } else { + None + } + }); + + if let Some(d) = dart_bin.as_ref() { + resolve_flutter_path(d) + } else if let Some(fvm_path) = Self::resolve_fvm_path() { + fvm_path + } else { + PathBuf::from("flutter") + } + } + + pub fn load_breakpoints(&mut self) { + let workspace_root = self + .app_state + .workspace_root_path() + .map(PathBuf::from) + .or_else(|| { + self.app_state + .active_file() + .and_then(|file| file.parent().map(PathBuf::from)) + }); + if let Some(root) = workspace_root { + let bp_path = root.join(".vscode").join("breakpoints.json"); + if bp_path.exists() { + if let Ok(content) = std::fs::read_to_string(&bp_path) { + if let Ok(bps) = serde_json::from_str::< + std::collections::HashMap>, + >(&content) + { + self.breakpoints = bps + .into_iter() + .map(|(p, l)| (p.canonicalize().unwrap_or(p), l)) + .collect(); + eprintln!( + "[DAP LOG] [Breakpoint Persist] Loaded breakpoints: {:?}", + self.breakpoints + ); + } + } + } + } + } + + pub fn save_breakpoints(&self) { + let workspace_root = self + .app_state + .workspace_root_path() + .map(PathBuf::from) + .or_else(|| { + self.app_state + .active_file() + .and_then(|file| file.parent().map(PathBuf::from)) + }); + if let Some(root) = workspace_root { + let vscode_dir = root.join(".vscode"); + if !vscode_dir.exists() { + let _ = std::fs::create_dir_all(&vscode_dir); + } + let bp_path = vscode_dir.join("breakpoints.json"); + if let Ok(content) = serde_json::to_string_pretty(&self.breakpoints) { + if let Err(e) = std::fs::write(&bp_path, content) { + eprintln!("Failed to write breakpoints.json: {}", e); + } + } + } + } +} diff --git a/src/app/event_loop/commands_explorer.rs b/src/app/event_loop/commands_explorer.rs index 8999c166..70dcc984 100644 --- a/src/app/event_loop/commands_explorer.rs +++ b/src/app/event_loop/commands_explorer.rs @@ -434,6 +434,13 @@ impl AppShell { Some(true) } Command::ExplorerMoveUp => { + if self.panel_state.left.active_tab_id() == Some(PanelTabId::Inspector) { + let changed = self.dap_panel_state.move_selection_prev(); + if changed { + self.sidebar_needs_layout = true; + } + return Some(changed); + } self.ensure_explorer_snapshot(); let entries_len = self.explorer_snapshot.entries.len(); if entries_len == 0 { @@ -453,6 +460,13 @@ impl AppShell { } } Command::ExplorerMoveDown => { + if self.panel_state.left.active_tab_id() == Some(PanelTabId::Inspector) { + let changed = self.dap_panel_state.move_selection_next(); + if changed { + self.sidebar_needs_layout = true; + } + return Some(changed); + } self.ensure_explorer_snapshot(); let entries_len = self.explorer_snapshot.entries.len(); if entries_len == 0 { @@ -621,6 +635,98 @@ impl AppShell { Command::ExplorerToggleOrOpen | Command::ExplorerExpandCollapse | Command::ExplorerOpenFile => { + if self.panel_state.left.active_tab_id() == Some(PanelTabId::Inspector) { + let rows = self.dap_panel_state.visible_rows(); + if let Some(row) = rows.get(self.dap_panel_state.selected_row) { + if row.section_index == 2 { + if let Some(path) = &row.node_path { + if !path.is_empty() { + let frame_idx = path[0]; + let jump_target = + self.dap_session.as_ref().and_then(|session| { + if let Ok(debug_guard) = session.state.try_lock() { + debug_guard.call_stack.get(frame_idx).and_then( + |frame| { + if frame.path.exists() { + Some(( + frame.path.clone(), + frame.location.line, + )) + } else { + None + } + }, + ) + } else { + None + } + }); + if let Some((target_path, target_line)) = jump_target { + let report = dispatch_command( + &mut self.app_state, + Command::OpenFile(target_path), + ); + if report.success { + self.app_state.jump_to_line_and_column(target_line, 0); + self.clear_highlight_layers(); + self.submit_active_buffer_git_baseline_refresh(); + self.submit_parse_for_active_buffer(true); + self.submit_lsp_did_open_for_active_file(); + self.editor_needs_layout = true; + self.editor_caret_needs_layout = true; + let _ = + self.focus_manager.set(FocusTarget::CenterEditor); + return Some(true); + } + } + } + } + } else if row.section_index == 3 { + if let Some(path) = &row.node_path { + if !path.is_empty() { + let bp_idx = path[0]; + let jump_target = + self.dap_session.as_ref().and_then(|session| { + if let Ok(debug_guard) = session.state.try_lock() { + debug_guard.breakpoints.get(bp_idx).and_then(|bp| { + if bp.path.exists() { + Some((bp.path.clone(), bp.location.line)) + } else { + None + } + }) + } else { + None + } + }); + if let Some((target_path, target_line)) = jump_target { + let report = dispatch_command( + &mut self.app_state, + Command::OpenFile(target_path), + ); + if report.success { + self.app_state.jump_to_line_and_column(target_line, 0); + self.clear_highlight_layers(); + self.submit_active_buffer_git_baseline_refresh(); + self.submit_parse_for_active_buffer(true); + self.submit_lsp_did_open_for_active_file(); + self.editor_needs_layout = true; + self.editor_caret_needs_layout = true; + let _ = + self.focus_manager.set(FocusTarget::CenterEditor); + return Some(true); + } + } + } + } + } + } + let changed = self.dap_panel_state.toggle_selected_expand(); + if changed { + self.sidebar_needs_layout = true; + } + return Some(changed); + } self.ensure_explorer_snapshot(); if self.explorer_snapshot.entries.is_empty() { self.explorer_cursor = 0; diff --git a/src/app/event_loop/commands_palette.rs b/src/app/event_loop/commands_palette.rs index 5f3680b1..1e918b1e 100644 --- a/src/app/event_loop/commands_palette.rs +++ b/src/app/event_loop/commands_palette.rs @@ -393,6 +393,47 @@ impl AppShell { return Some(self.confirm_lsp_rename_prompt()); } + if matches!(command, Command::FilePickerConfirmSelection) + && matches!( + self.app_state.command_palette_mode(), + Some(CommandPaletteMode::FlutterDevices) + ) + { + let changed = self.confirm_flutter_device_selection(); + let _ = dispatch_command(&mut self.app_state, Command::CloseFilePicker); + return Some(changed); + } + + if matches!(command, Command::FilePickerConfirmSelection) + && matches!( + self.app_state.command_palette_mode(), + Some(CommandPaletteMode::DebugWatchInput) + ) + { + let expr = self + .app_state + .command_palette_query_text() + .trim() + .to_string(); + let _ = dispatch_command(&mut self.app_state, Command::CloseFilePicker); + if !expr.is_empty() { + if let Some(session) = &self.dap_session { + if let Ok(mut state) = session.state.try_lock() { + state.add_watch_expression(expr.clone()); + } + let session_clone = session.clone(); + tokio::spawn(async move { + let _ = session_clone.evaluate_watch_expressions().await; + }); + if let Ok(state_guard) = session.state.try_lock() { + self.dap_panel_state.sync_from_debug_state(&state_guard); + } + self.sidebar_needs_layout = true; + } + } + return Some(true); + } + if matches!(command, Command::FilePickerConfirmSelection) && matches!( self.app_state.command_palette_mode(), @@ -449,6 +490,21 @@ impl AppShell { } else { None }; + // Capture the command ID before dispatch closes the palette. + let pending_execute_command_id: Option = + if matches!(command, Command::FilePickerConfirmSelection) + && palette_mode_before == Some(CommandPaletteMode::CommandPalette) + { + if let Some(CommandPaletteAction::ExecuteCommand(ref id)) = + self.app_state.command_palette_selected_action() + { + Some(id.clone()) + } else { + None + } + } else { + None + }; let confirmed_from_fuzzy_picker = matches!(command, Command::FilePickerConfirmSelection) && self.app_state.active_buffer_is_fuzzy_picker(); @@ -517,6 +573,22 @@ impl AppShell { return Some(true); } + // When ExecuteCommand dispatches FlutterDevices, trigger the device scan. + if report.success + && pending_execute_command_id.as_deref() == Some("flutter.devices") + { + self.app_state.open_flutter_device_selector(); + let flutter_path = self.current_flutter_path(); + self.submit(RequestSpec { + revision_id: 0, + topic: RequestTopic::SystemTask, + payload: WorkerRequestPayload::ScanFlutterDevices { + flutter_path: Some(flutter_path), + }, + }); + return Some(true); + } + let file_after = self.app_state.active_file().map(PathBuf::from); let file_changed = report.success && file_after != file_before; let mut parsed_after_file_change = false; diff --git a/src/app/event_loop/commands_settings.rs b/src/app/event_loop/commands_settings.rs index d55d751a..d3494698 100644 --- a/src/app/event_loop/commands_settings.rs +++ b/src/app/event_loop/commands_settings.rs @@ -450,10 +450,7 @@ fn missing_install_prerequisite(install_cmd: &str) -> Option<(&'static str, &'st "go", "Install Go first: https://go.dev/dl or `brew install go`.", ), - "cargo" | "rustup" => ( - "cargo", - "Install Rust first: https://rustup.rs.", - ), + "cargo" | "rustup" => ("cargo", "Install Rust first: https://rustup.rs."), "pip" | "pip3" | "pipx" => ( "pip3", "Install Python 3 first: https://www.python.org or `brew install python`.", diff --git a/src/app/event_loop/commands_settings_helpers.rs b/src/app/event_loop/commands_settings_helpers.rs index d63f65ac..738e960a 100644 --- a/src/app/event_loop/commands_settings_helpers.rs +++ b/src/app/event_loop/commands_settings_helpers.rs @@ -329,7 +329,8 @@ impl AppShell { self.apply_ai_number_adjust( |ai| ai.set_inline_prefix_chars(next), |item| { - if let crate::app::app_state::SettingItem::AiPrefixChars { current } = item { + if let crate::app::app_state::SettingItem::AiPrefixChars { current } = item + { *current = next; } }, @@ -341,7 +342,8 @@ impl AppShell { self.apply_ai_number_adjust( |ai| ai.set_inline_suffix_chars(next), |item| { - if let crate::app::app_state::SettingItem::AiSuffixChars { current } = item { + if let crate::app::app_state::SettingItem::AiSuffixChars { current } = item + { *current = next; } }, @@ -746,7 +748,8 @@ impl AppShell { changed = self.commit_ai_number_edit( |ai| ai.set_inline_max_tokens(value), |item| { - if let crate::app::app_state::SettingItem::AiMaxTokens { current } = item + if let crate::app::app_state::SettingItem::AiMaxTokens { current } = + item { *current = value; } diff --git a/src/app/event_loop/commands_terminal.rs b/src/app/event_loop/commands_terminal.rs index edc3b6c9..c8fb1a44 100644 --- a/src/app/event_loop/commands_terminal.rs +++ b/src/app/event_loop/commands_terminal.rs @@ -448,16 +448,46 @@ impl AppShell { } fn handle_switch_terminal_tab(&mut self, idx: usize) -> bool { - if idx >= self.terminal_tabs.len() { + if idx >= self.panel_state.bottom.tabs.len() { return false; } - if self.active_terminal_tab == idx { - return false; + + let mut changed = false; + if self.panel_state.bottom.active_tab != idx { + self.panel_state.bottom.active_tab = idx; + changed = true; + } + + if !self.panel_state.bottom.visible { + self.panel_state.bottom.visible = true; + changed = true; } - self.active_terminal_tab = idx; + + // Focus the bottom panel + let focus_changed = self.focus_manager.set(FocusTarget::BottomPanel); + changed |= focus_changed; + + // If the switched tab is Terminal, apply ModeEvent::FocusTerminal. + // If the switched tab is DebugConsole, we should exit terminal focus mode (transition back to Normal). + if self.panel_state.bottom.tabs[idx] == PanelTabId::Terminal { + self.ensure_active_terminal_tab_spawned(); + if let Ok(result) = self.app_state.apply_mode_event(ModeEvent::FocusTerminal) { + changed |= result.changed; + } + } else { + // Switch away from terminal mode to Normal (exit terminal focus) + if matches!( + self.app_state.current_mode(), + EditorMode::TerminalFocus | EditorMode::TerminalNormal + ) { + if let Ok(result) = self.app_state.apply_mode_event(ModeEvent::ExitFocus) { + changed |= result.changed; + } + } + } + self.terminal_needs_layout = true; - self.ensure_active_terminal_tab_spawned(); - true + changed } /// Handle search commands when in Terminal Normal Mode. diff --git a/src/app/event_loop/commands_tests.rs b/src/app/event_loop/commands_tests.rs index abdfd89a..c71ace25 100644 --- a/src/app/event_loop/commands_tests.rs +++ b/src/app/event_loop/commands_tests.rs @@ -273,17 +273,40 @@ fn settings_exposes_ai_inline_config_items() { // The AI section must surface the editable endpoint/model/tuning fields, not // just the on/off toggle — otherwise they would only be reachable by hand- // editing config/ai.toml. - let has = |pred: fn(&crate::app::app_state::SettingItem) -> bool| { - settings.items.iter().any(pred) - }; - assert!(has(|i| matches!(i, crate::app::app_state::SettingItem::AiApiUrl { .. }))); - assert!(has(|i| matches!(i, crate::app::app_state::SettingItem::AiModel { .. }))); - assert!(has(|i| matches!(i, crate::app::app_state::SettingItem::AiApiKey { .. }))); - assert!(has(|i| matches!(i, crate::app::app_state::SettingItem::AiEndpointKind { .. }))); - assert!(has(|i| matches!(i, crate::app::app_state::SettingItem::AiMaxTokens { .. }))); - assert!(has(|i| matches!(i, crate::app::app_state::SettingItem::AiPrefixChars { .. }))); - assert!(has(|i| matches!(i, crate::app::app_state::SettingItem::AiSuffixChars { .. }))); - assert!(has(|i| matches!(i, crate::app::app_state::SettingItem::AiDebounceMs { .. }))); + let has = + |pred: fn(&crate::app::app_state::SettingItem) -> bool| settings.items.iter().any(pred); + assert!(has(|i| matches!( + i, + crate::app::app_state::SettingItem::AiApiUrl { .. } + ))); + assert!(has(|i| matches!( + i, + crate::app::app_state::SettingItem::AiModel { .. } + ))); + assert!(has(|i| matches!( + i, + crate::app::app_state::SettingItem::AiApiKey { .. } + ))); + assert!(has(|i| matches!( + i, + crate::app::app_state::SettingItem::AiEndpointKind { .. } + ))); + assert!(has(|i| matches!( + i, + crate::app::app_state::SettingItem::AiMaxTokens { .. } + ))); + assert!(has(|i| matches!( + i, + crate::app::app_state::SettingItem::AiPrefixChars { .. } + ))); + assert!(has(|i| matches!( + i, + crate::app::app_state::SettingItem::AiSuffixChars { .. } + ))); + assert!(has(|i| matches!( + i, + crate::app::app_state::SettingItem::AiDebounceMs { .. } + ))); } #[test] @@ -3222,6 +3245,48 @@ fn resize_panel_and_ui_config_stay_in_sync() { ); } +#[test] +fn focus_dap_opens_and_focuses_inspector() { + let mut shell = AppShell::new_for_tests().expect("create app shell"); + shell.panel_state.left.visible = false; + + assert!(shell.handle_command(Command::FocusDap)); + assert!(shell.panel_state.left.visible); + assert_eq!( + shell.panel_state.left.active_tab_id(), + Some(PanelTabId::Inspector) + ); + assert_eq!(shell.focus_manager.current(), FocusTarget::LeftSidebar); +} + +#[test] +fn dap_and_flutter_command_ids_survive_main_merge() { + use crate::core::command_ids; + + for id in [ + command_ids::FOCUS_DAP, + command_ids::DAP_TOGGLE_EXPAND, + command_ids::DEBUG_START, + command_ids::DEBUG_STOP, + command_ids::DEBUG_STEP_OVER, + command_ids::DEBUG_STEP_INTO, + command_ids::DEBUG_STEP_OUT, + command_ids::DEBUG_TOGGLE_BREAKPOINT, + command_ids::FLUTTER_DEVICES, + command_ids::FLUTTER_HOT_RELOAD, + command_ids::FLUTTER_HOT_RESTART, + ] { + assert!( + command_ids::ALL_IDS.contains(&id), + "missing command id: {id}" + ); + assert!( + command_ids::parse(id, None).is_some(), + "unparsed command id: {id}" + ); + } +} + #[test] fn manual_trigger_completion_dismisses_ghost_text_and_invalidates_inflight_ai() { let mut shell = AppShell::new_for_tests().expect("create app shell"); diff --git a/src/app/event_loop/mod.rs b/src/app/event_loop/mod.rs index f7d4864f..c260f53d 100644 --- a/src/app/event_loop/mod.rs +++ b/src/app/event_loop/mod.rs @@ -131,6 +131,11 @@ pub struct AppShell { explorer_clipboard_path: Option, pending_confirmation: Option, workspace_git_branch: Option, + dap_session: Option>, + breakpoints: std::collections::HashMap>, + dap_panel_state: crate::workbench::inspector_panel::InspectorPanelState, + dap_console_grid: TerminalGrid, + dap_console_fed_count: usize, active_lsp_server: Option, pending_lsp_server: Option, lsp_completion_trigger_chars: Vec, diff --git a/src/app/event_loop/setup.rs b/src/app/event_loop/setup.rs index 257c93aa..345a61fb 100644 --- a/src/app/event_loop/setup.rs +++ b/src/app/event_loop/setup.rs @@ -179,6 +179,15 @@ impl AppShell { explorer_clipboard_path: None, pending_confirmation: None, workspace_git_branch, + dap_session: None, + breakpoints: HashMap::new(), + dap_panel_state: crate::workbench::inspector_panel::InspectorPanelState::default(), + dap_console_grid: { + let mut g = TerminalGrid::new(120, 40); + g.highlight_colors = HighlightColors::from_theme(&theme); + g + }, + dap_console_fed_count: 0, active_lsp_server: None, pending_lsp_server: None, lsp_completion_trigger_chars: Vec::new(), @@ -281,6 +290,7 @@ impl AppShell { } pub(super) fn startup_subsystems(&mut self) { + self.load_breakpoints(); let cwd = std::env::current_dir().unwrap_or_default(); let workspace_root = self .app_state @@ -672,6 +682,9 @@ impl AppShell { } } } + if self.dap_console_grid.resize(cols, rows) { + grid_changed = true; + } if grid_changed { self.terminal_needs_layout = true; } @@ -740,7 +753,13 @@ impl AppShell { InputFocusContext::Welcome } else { match self.focus_manager.current() { - FocusTarget::LeftSidebar => InputFocusContext::Explorer, + FocusTarget::LeftSidebar => { + if self.panel_state.left.active_tab_id() == Some(PanelTabId::Inspector) { + InputFocusContext::Inspector + } else { + InputFocusContext::Explorer + } + } FocusTarget::RightSidebar => match self.panel_state.right.active_tab_id() { Some(PanelTabId::AiChat) => InputFocusContext::AiChat, Some(PanelTabId::MarkdownPreview) => InputFocusContext::MarkdownPreview, diff --git a/src/app/input/handler.rs b/src/app/input/handler.rs index ab4b7979..906a24cb 100644 --- a/src/app/input/handler.rs +++ b/src/app/input/handler.rs @@ -200,10 +200,24 @@ impl InputHandler { } let normalized = NormalizedInput::from_key_event(key_event, self.modifiers); + let is_f5 = normalized.named_key == Some(NamedKey::F5); + if is_f5 { + eprintln!( + "[DAP LOG] [F5 Input Handler] translate_key_event: normalized F5 key_event, modifiers: {:?}", + self.modifiers + ); + } if key_event.repeat { return self.route_repeated_normalized_input(normalized, input_map, context); } - self.route_normalized_input(normalized, input_map, context, Instant::now()) + let outcome = self.route_normalized_input(normalized, input_map, context, Instant::now()); + if is_f5 { + eprintln!( + "[DAP LOG] [F5 Input Handler] translate_key_event F5 outcome: {:?}", + outcome + ); + } + outcome } /// In the right-sidebar opencode chat, Ctrl+U/Ctrl+D scroll the chat history diff --git a/src/app/input/tests.rs b/src/app/input/tests.rs index 8a27aa8c..05f8fa73 100644 --- a/src/app/input/tests.rs +++ b/src/app/input/tests.rs @@ -393,19 +393,17 @@ fn bottom_terminal_ctrl_u_d_still_forward_raw_to_pty() { let up = handler.route_normalized_input(ctrl_input('u', KeyCode::KeyU), &map, context, t0); match up { - Some(InputRouteOutcome::Dispatch(translated)) => { - match &translated.command { - Command::TerminalWriteInput(payload) => { - assert!( - !payload.starts_with('\u{1b}'), - "bottom terminal Ctrl+U must stay raw ^U, not the right-dock \ + Some(InputRouteOutcome::Dispatch(translated)) => match &translated.command { + Command::TerminalWriteInput(payload) => { + assert!( + !payload.starts_with('\u{1b}'), + "bottom terminal Ctrl+U must stay raw ^U, not the right-dock \ ctrl+alt+u forward, got {:?}", - payload - ); - } - other => panic!("bottom terminal Ctrl+U should forward raw, got {:?}", other), + payload + ); } - } + other => panic!("bottom terminal Ctrl+U should forward raw, got {:?}", other), + }, other => panic!("expected raw write dispatch, got {:?}", other), } } @@ -1079,7 +1077,8 @@ fn bare_find_char_motion_waits_then_dispatches_move_find_char() { let context = KeybindingContext::for_mode(EditorMode::Normal); let now = std::time::Instant::now(); - let pending = handler.route_normalized_input(char_input('f', KeyCode::KeyF), &map, context, now); + let pending = + handler.route_normalized_input(char_input('f', KeyCode::KeyF), &map, context, now); assert!(matches!( pending, Some(InputRouteOutcome::NoDispatch { .. }) diff --git a/src/app/input_map/focus.rs b/src/app/input_map/focus.rs index 7397e879..d0b46c0a 100644 --- a/src/app/input_map/focus.rs +++ b/src/app/input_map/focus.rs @@ -488,6 +488,22 @@ impl InputMap { }); } + if !input.has_command_modifier() && input.named_key == Some(NamedKey::Enter) { + return Some(KeybindingMatch { + command: Command::DebugGotoFrame, + reason: "inspector: Enter -> DebugGotoFrame (jump to call stack frame)", + }); + } + + if let Some(command) = + resolved_keymap::resolve_command(&self.keymap, input, "normal", &self.open_file_path) + { + return Some(KeybindingMatch { + command, + reason: "inspector: keymap binding", + }); + } + resolved_keymap::resolve_global_command(&self.keymap, input, &self.open_file_path).map( |command| KeybindingMatch { command, @@ -1056,6 +1072,43 @@ impl InputMap { }); } + if let Some(command) = + resolved_keymap::resolve_global_command(&self.keymap, input, &self.open_file_path) + { + let is_allowed = match &command { + Command::FocusTerminal + | Command::ToggleBottomDock + | Command::FocusDap + | Command::FocusExplorer + | Command::FocusInspector + | Command::SwitchTerminalTab(_) + | Command::SaveFile + | Command::OpenFile(_) + | Command::OpenFilePicker + | Command::OpenCommandPalette + | Command::ToggleLeftDock + | Command::DebugStart + | Command::FlutterHotRestart + | Command::DebugStop + | Command::DebugStepOver + | Command::DebugStepInto + | Command::DebugStepOut => true, + Command::DebugToggleBreakpoint => { + !(input.modifiers.shift_key() + && !input.modifiers.control_key() + && !input.modifiers.super_key() + && !input.modifiers.alt_key()) + } + _ => false, + }; + if is_allowed { + return Some(KeybindingMatch { + command, + reason: "terminal focus: global keymap bypass", + }); + } + } + if mode == EditorMode::TerminalFocus && input.named_key == Some(NamedKey::Escape) { return Some(KeybindingMatch { command: Command::FocusBack, diff --git a/src/app/input_map/tests.rs b/src/app/input_map/tests.rs index 78e63299..0653ee97 100644 --- a/src/app/input_map/tests.rs +++ b/src/app/input_map/tests.rs @@ -147,6 +147,15 @@ fn table_driven_keybinding_resolution() { }, expected: None, }, + Case { + name: "terminal focus F5 -> DebugStart", + context: KeybindingContext::with_focus( + EditorMode::TerminalFocus, + InputFocusContext::Terminal, + ), + input: input_from_named(NamedKey::F5), + expected: Some(Command::DebugStart), + }, Case { name: "terminal focus cmd+v -> TerminalPaste", context: KeybindingContext::with_focus( @@ -249,6 +258,20 @@ fn table_driven_keybinding_resolution() { }, expected: Some(Command::TerminalPaste), }, + Case { + name: "terminal normal cmd+3 -> SwitchTerminalTab", + context: KeybindingContext::with_focus( + EditorMode::TerminalNormal, + InputFocusContext::Terminal, + ), + input: NormalizedInput { + physical_key: Some(KeyCode::Digit3), + named_key: None, + text: Some("3".to_string()), + modifiers: ModifiersState::SUPER, + }, + expected: Some(Command::SwitchTerminalTab(2)), + }, Case { name: "terminal normal Shift+V -> EnterVisualLine", context: KeybindingContext::with_focus( @@ -357,6 +380,12 @@ fn table_driven_keybinding_resolution() { input: input_from_named(NamedKey::F12), expected: Some(Command::FocusTerminal), }, + Case { + name: "global F9 -> DebugToggleBreakpoint", + context: KeybindingContext::for_mode(EditorMode::Normal), + input: input_from_named(NamedKey::F9), + expected: Some(Command::DebugToggleBreakpoint), + }, Case { name: "global cmd+backslash -> ToggleBottomDock", context: KeybindingContext::for_mode(EditorMode::Normal), diff --git a/src/app/resolved_keymap.rs b/src/app/resolved_keymap.rs index e1499b3b..0801f303 100644 --- a/src/app/resolved_keymap.rs +++ b/src/app/resolved_keymap.rs @@ -227,6 +227,16 @@ fn parse_non_leader_key(token: &str) -> Option { "arrowright" => Some(NamedKey::ArrowRight), "tab" => Some(NamedKey::Tab), "f1" => Some(NamedKey::F1), + "f2" => Some(NamedKey::F2), + "f3" => Some(NamedKey::F3), + "f4" => Some(NamedKey::F4), + "f5" => Some(NamedKey::F5), + "f6" => Some(NamedKey::F6), + "f7" => Some(NamedKey::F7), + "f8" => Some(NamedKey::F8), + "f9" => Some(NamedKey::F9), + "f10" => Some(NamedKey::F10), + "f11" => Some(NamedKey::F11), "f12" => Some(NamedKey::F12), _ => None, }; @@ -646,10 +656,36 @@ pub fn builtin_defaults() -> ResolvedKeymap { ); km.insert(None, cmd(KeyCode::KeyB), TOGGLE_LEFT_DOCK); km.insert(None, KeySpec::CtrlPlus(KeyCode::KeyF), FOCUS_EXPLORER); + km.insert(None, KeySpec::CtrlPlus(KeyCode::KeyB), FOCUS_DAP); km.insert(None, cmd(KeyCode::KeyR), FOCUS_INSPECTOR); km.insert(None, nk(NamedKey::F12), FOCUS_TERMINAL); km.insert(None, cmd(KeyCode::Backslash), TOGGLE_BOTTOM_DOCK); + // ── Global Tab switching ────────────────────────────────────────────────── + km.insert(None, cmd(KeyCode::Digit1), TERMINAL_TAB_SWITCH_1); + km.insert(None, cmd(KeyCode::Digit2), TERMINAL_TAB_SWITCH_2); + km.insert(None, cmd(KeyCode::Digit3), TERMINAL_TAB_SWITCH_3); + km.insert(None, cmd(KeyCode::Digit4), TERMINAL_TAB_SWITCH_4); + km.insert(None, cmd(KeyCode::Digit5), TERMINAL_TAB_SWITCH_5); + km.insert(None, cmd(KeyCode::Digit6), TERMINAL_TAB_SWITCH_6); + km.insert(None, cmd(KeyCode::Digit7), TERMINAL_TAB_SWITCH_7); + km.insert(None, cmd(KeyCode::Digit8), TERMINAL_TAB_SWITCH_8); + km.insert(None, cmd(KeyCode::Digit9), TERMINAL_TAB_SWITCH_9); + + // ── Debugger (DAP) function keys ───────────────────────────────────────── + km.insert(None, nk(NamedKey::F5), DEBUG_START); + km.insert(None, nk(NamedKey::F6), FLUTTER_HOT_RESTART); + km.insert(None, KeySpec::ShiftPlus(KeyCode::F5), DEBUG_STOP); + km.insert(None, nk(NamedKey::F10), DEBUG_STEP_OVER); + km.insert(None, nk(NamedKey::F11), DEBUG_STEP_INTO); + km.insert(None, KeySpec::ShiftPlus(KeyCode::F11), DEBUG_STEP_OUT); + km.insert(None, nk(NamedKey::F9), DEBUG_TOGGLE_BREAKPOINT); + km.insert( + None, + KeySpec::ShiftPlus(KeyCode::KeyB), + DEBUG_TOGGLE_BREAKPOINT, + ); + // ── Insert mode ─────────────────────────────────────────────────────────── km.insert(Some("insert"), nk(NamedKey::Escape), ENTER_NORMAL); km.insert(Some("insert"), nk(NamedKey::Backspace), BACKSPACE); @@ -1132,6 +1168,36 @@ pub fn builtin_defaults() -> ResolvedKeymap { seq(&[KeySpec::Leader, ph(KeyCode::KeyI)]), FOCUS_INSPECTOR, ); + km.insert_sequence( + None, + seq(&[KeySpec::Leader, ph(KeyCode::KeyD), ph(KeyCode::KeyB)]), + FOCUS_DAP, + ); + km.insert_sequence( + Some("normal"), + seq(&[ph(KeyCode::KeyZ), ph(KeyCode::KeyA)]), + DAP_TOGGLE_EXPAND, + ); + km.insert_sequence( + None, + seq(&[KeySpec::Leader, ph(KeyCode::KeyF), ph(KeyCode::KeyD)]), + FLUTTER_DEVICES, + ); + km.insert_sequence( + None, + seq(&[KeySpec::Leader, ph(KeyCode::KeyF), ph(KeyCode::KeyH)]), + FLUTTER_HOT_RELOAD, + ); + km.insert_sequence( + None, + seq(&[KeySpec::Leader, ph(KeyCode::KeyF), ph(KeyCode::KeyR)]), + FLUTTER_HOT_RESTART, + ); + km.insert_sequence( + None, + seq(&[KeySpec::Leader, ph(KeyCode::KeyF), ph(KeyCode::KeyQ)]), + DEBUG_STOP, + ); km.insert_sequence( None, seq(&[KeySpec::Leader, ph(KeyCode::KeyF), ph(KeyCode::KeyF)]), @@ -1259,6 +1325,10 @@ mod tests { parse_key_spec("ArrowUp"), Some(KeySpec::Named(NamedKey::ArrowUp)) ); + assert_eq!(parse_key_spec("F2"), Some(KeySpec::Named(NamedKey::F2))); + assert_eq!(parse_key_spec("F5"), Some(KeySpec::Named(NamedKey::F5))); + assert_eq!(parse_key_spec("F11"), Some(KeySpec::Named(NamedKey::F11))); + assert_eq!(parse_key_spec("F12"), Some(KeySpec::Named(NamedKey::F12))); } #[test] diff --git a/src/async_runtime/flutter_device.rs b/src/async_runtime/flutter_device.rs new file mode 100644 index 00000000..4da3f917 --- /dev/null +++ b/src/async_runtime/flutter_device.rs @@ -0,0 +1,589 @@ +use serde::{Deserialize, Serialize}; +use std::process::Stdio; +use tokio::process::Command; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct FlutterDevice { + pub id: String, + pub name: String, + pub platform: String, + pub emulator: bool, + pub is_active: bool, +} + +fn resolve_sdk_dir() -> Option { + if let Ok(path) = std::env::var("ANDROID_HOME") { + let p = std::path::PathBuf::from(path); + if p.exists() { + return Some(p); + } + } + if let Ok(path) = std::env::var("ANDROID_SDK_ROOT") { + let p = std::path::PathBuf::from(path); + if p.exists() { + return Some(p); + } + } + if let Ok(home) = std::env::var("HOME") { + let p = std::path::Path::new(&home) + .join("Library") + .join("Android") + .join("sdk"); + if p.exists() { + return Some(p); + } + } + None +} + +fn resolve_adb_path() -> String { + if let Some(sdk_dir) = resolve_sdk_dir() { + let p = sdk_dir.join("platform-tools").join("adb"); + if p.exists() { + return p.to_string_lossy().to_string(); + } + } + for p in &[ + "/opt/homebrew/bin/adb", + "/usr/local/bin/adb", + "/usr/bin/adb", + ] { + if std::path::Path::new(p).exists() { + return p.to_string().to_string(); + } + } + "adb".to_string() +} + +fn resolve_emulator_path() -> String { + if let Some(sdk_dir) = resolve_sdk_dir() { + let p = sdk_dir.join("emulator").join("emulator"); + if p.exists() { + return p.to_string_lossy().to_string(); + } + } + for p in &[ + "/opt/homebrew/bin/emulator", + "/usr/local/bin/emulator", + "/usr/bin/emulator", + ] { + if std::path::Path::new(p).exists() { + return p.to_string().to_string(); + } + } + "emulator".to_string() +} + +fn make_flutter_command(flutter_path: &str) -> Command { + if flutter_path.ends_with("fvm") { + let mut cmd = Command::new(flutter_path); + cmd.arg("flutter"); + cmd + } else { + Command::new(flutter_path) + } +} + +#[derive(Deserialize, Debug)] +#[serde(rename_all = "camelCase")] +struct RawFlutterDevice { + name: String, + id: String, + target_platform: String, + emulator: bool, +} + +#[derive(Deserialize, Debug)] +#[serde(rename_all = "camelCase")] +struct RawFlutterEmulator { + id: String, + name: String, + platform_type: String, +} + +#[derive(Deserialize, Debug)] +struct SimctlOutput { + devices: std::collections::HashMap>, +} + +#[derive(Deserialize, Debug)] +struct SimctlDevice { + name: String, + udid: String, + state: String, + #[serde(rename = "isAvailable")] + is_available: serde_json::Value, +} + +pub async fn scan_flutter_devices(flutter_path: Option) -> Vec { + eprintln!("[scan_flutter_devices] Starting parallel device scan..."); + let flutter_cmd = flutter_path + .map(|p| p.to_string_lossy().to_string()) + .unwrap_or_else(|| "flutter".to_string()); + + let timeout_duration = std::time::Duration::from_secs(5); + + // 1. Run flutter devices --machine + let f1_cmd = flutter_cmd.clone(); + let handle_flutter_devices = tokio::spawn(async move { + let mut list = Vec::new(); + eprintln!("[scan_flutter_devices] Spawning 'flutter devices --machine'..."); + let res = tokio::time::timeout( + timeout_duration, + make_flutter_command(&f1_cmd) + .args(&["devices", "--machine"]) + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::null()) + .output(), + ) + .await; + match res { + Ok(Ok(output)) if output.status.success() => { + if let Ok(raw_devices) = + serde_json::from_slice::>(&output.stdout) + { + eprintln!( + "[scan_flutter_devices] 'flutter devices' returned {} devices", + raw_devices.len() + ); + for dev in raw_devices { + list.push(FlutterDevice { + id: dev.id, + name: dev.name, + platform: dev.target_platform, + emulator: dev.emulator, + is_active: true, + }); + } + } else { + eprintln!("[scan_flutter_devices] Failed to parse 'flutter devices' JSON"); + } + } + Ok(Ok(output)) => { + eprintln!( + "[scan_flutter_devices] 'flutter devices' failed with status: {:?}", + output.status + ); + } + Ok(Err(e)) => { + eprintln!( + "[scan_flutter_devices] Failed to run 'flutter devices': {:?}", + e + ); + } + Err(_) => { + eprintln!("[scan_flutter_devices] 'flutter devices' timed out after 5s"); + } + } + list + }); + + // 2. Run flutter emulators --machine + let f2_cmd = flutter_cmd.clone(); + let handle_flutter_emulators = tokio::spawn(async move { + let mut list = Vec::new(); + eprintln!("[scan_flutter_devices] Spawning 'flutter emulators --machine'..."); + let res = tokio::time::timeout( + timeout_duration, + make_flutter_command(&f2_cmd) + .args(&["emulators", "--machine"]) + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::null()) + .output(), + ) + .await; + match res { + Ok(Ok(output)) if output.status.success() => { + if let Ok(raw_emulators) = + serde_json::from_slice::>(&output.stdout) + { + eprintln!( + "[scan_flutter_devices] 'flutter emulators' returned {} emulators", + raw_emulators.len() + ); + for emu in raw_emulators { + list.push(FlutterDevice { + id: emu.id, + name: emu.name, + platform: emu.platform_type, + emulator: true, + is_active: false, + }); + } + } else { + eprintln!("[scan_flutter_devices] Failed to parse 'flutter emulators' JSON"); + } + } + Ok(Ok(output)) => { + eprintln!( + "[scan_flutter_devices] 'flutter emulators' failed with status: {:?}", + output.status + ); + } + Ok(Err(e)) => { + eprintln!( + "[scan_flutter_devices] Failed to run 'flutter emulators': {:?}", + e + ); + } + Err(_) => { + eprintln!("[scan_flutter_devices] 'flutter emulators' timed out after 5s"); + } + } + list + }); + + // 3. Native iOS Simulator scan via xcrun simctl + let handle_simctl = tokio::spawn(async move { + let mut list = Vec::new(); + eprintln!("[scan_flutter_devices] Spawning 'xcrun simctl list devices --json'..."); + let res = tokio::time::timeout( + timeout_duration, + Command::new("xcrun") + .args(&["simctl", "list", "devices", "--json"]) + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::null()) + .output(), + ) + .await; + match res { + Ok(Ok(output)) if output.status.success() => { + if let Ok(simctl_data) = serde_json::from_slice::(&output.stdout) { + let mut count = 0; + for (_runtime, devs) in simctl_data.devices { + for dev in devs { + let is_avail = match &dev.is_available { + serde_json::Value::Bool(b) => *b, + serde_json::Value::String(s) => { + s.to_lowercase() == "yes" || s.to_lowercase() == "true" + } + _ => true, + }; + if is_avail { + count += 1; + let is_active = dev.state == "Booted"; + list.push(FlutterDevice { + id: dev.udid, + name: dev.name, + platform: "ios".to_string(), + emulator: true, + is_active, + }); + } + } + } + eprintln!( + "[scan_flutter_devices] 'xcrun simctl' returned {} available simulators", + count + ); + } else { + eprintln!("[scan_flutter_devices] Failed to parse 'xcrun simctl' JSON"); + } + } + Ok(Ok(output)) => { + eprintln!( + "[scan_flutter_devices] 'xcrun simctl' failed with status: {:?}", + output.status + ); + } + Ok(Err(e)) => { + eprintln!( + "[scan_flutter_devices] Failed to run 'xcrun simctl': {:?}", + e + ); + } + Err(_) => { + eprintln!("[scan_flutter_devices] 'xcrun simctl' timed out after 5s"); + } + } + list + }); + + // 4. Native Android AVDs scan via emulator -list-avds + let emulator_cmd = resolve_emulator_path(); + let adb_cmd = resolve_adb_path(); + + let e_cmd = emulator_cmd.clone(); + let handle_emulator = tokio::spawn(async move { + let mut list = Vec::new(); + eprintln!("[scan_flutter_devices] Spawning '{} -list-avds'...", e_cmd); + let res = tokio::time::timeout( + timeout_duration, + Command::new(&e_cmd) + .arg("-list-avds") + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::null()) + .output(), + ) + .await; + match res { + Ok(Ok(output)) if output.status.success() => { + let stdout_str = String::from_utf8_lossy(&output.stdout); + let mut count = 0; + for line in stdout_str.lines() { + let avd_name = line.trim(); + if !avd_name.is_empty() { + count += 1; + list.push(FlutterDevice { + id: avd_name.to_string(), + name: avd_name.replace('_', " "), + platform: "android".to_string(), + emulator: true, + is_active: false, + }); + } + } + eprintln!( + "[scan_flutter_devices] Android emulator list returned {} AVDs", + count + ); + } + Ok(Ok(output)) => { + eprintln!( + "[scan_flutter_devices] Android emulator list failed with status: {:?}", + output.status + ); + } + Ok(Err(e)) => { + eprintln!( + "[scan_flutter_devices] Failed to run Android emulator list: {:?}", + e + ); + } + Err(_) => { + eprintln!("[scan_flutter_devices] Android emulator list timed out after 5s"); + } + } + list + }); + + // 5. Native active Android devices scan via adb devices + let a_cmd = adb_cmd.clone(); + let handle_adb = tokio::spawn(async move { + let mut list = Vec::new(); + eprintln!("[scan_flutter_devices] Spawning '{} devices'...", a_cmd); + let res = tokio::time::timeout( + timeout_duration, + Command::new(&a_cmd) + .arg("devices") + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::null()) + .output(), + ) + .await; + match res { + Ok(Ok(output)) if output.status.success() => { + let stdout_str = String::from_utf8_lossy(&output.stdout); + let mut count = 0; + for line in stdout_str.lines() { + let parts: Vec<&str> = line.split_whitespace().collect(); + if parts.len() == 2 && parts[1] == "device" { + let id = parts[0]; + count += 1; + list.push(FlutterDevice { + id: id.to_string(), + name: id.to_string(), + platform: "android".to_string(), + emulator: id.starts_with("emulator-"), + is_active: true, + }); + } + } + eprintln!( + "[scan_flutter_devices] adb devices returned {} active devices", + count + ); + } + Ok(Ok(output)) => { + eprintln!( + "[scan_flutter_devices] adb devices failed with status: {:?}", + output.status + ); + } + Ok(Err(e)) => { + eprintln!("[scan_flutter_devices] Failed to run adb devices: {:?}", e); + } + Err(_) => { + eprintln!("[scan_flutter_devices] adb devices timed out after 5s"); + } + } + list + }); + + // Wait for all branches to complete + let flutter_devs = handle_flutter_devices.await.unwrap_or_default(); + let flutter_emus = handle_flutter_emulators.await.unwrap_or_default(); + let simctl_devs = handle_simctl.await.unwrap_or_default(); + let avd_emus = handle_emulator.await.unwrap_or_default(); + let adb_devs = handle_adb.await.unwrap_or_default(); + + let mut devices = Vec::new(); + + // Populate flutter devices and emulators + for d in flutter_devs { + if !devices + .iter() + .any(|existing: &FlutterDevice| existing.id == d.id) + { + devices.push(d); + } + } + for d in flutter_emus { + if !devices + .iter() + .any(|existing: &FlutterDevice| existing.id == d.id) + { + devices.push(d); + } + } + + // Populate iOS simulators + for d in simctl_devs { + if !devices + .iter() + .any(|existing: &FlutterDevice| existing.id == d.id) + { + devices.push(d); + } + } + + // Populate Android AVDs + for d in avd_emus { + if !devices + .iter() + .any(|existing: &FlutterDevice| existing.id == d.id) + { + devices.push(d); + } + } + + // Sync active state from ADB list + for d in adb_devs { + let mut found = false; + for existing in &mut devices { + if existing.id == d.id + || (d.id.starts_with("emulator-") && existing.platform == "android") + { + existing.is_active = true; + found = true; + } + } + if !found { + devices.push(d); + } + } + + eprintln!( + "[scan_flutter_devices] Scan completed. Found {} total devices", + devices.len() + ); + devices +} + +pub async fn launch_flutter_emulator( + flutter_path: Option, + emulator_id: &str, +) -> Result<(), String> { + // 1. iOS Simulator boot via xcrun simctl + let is_ios = emulator_id.contains('-') && emulator_id.len() >= 36; + if is_ios { + eprintln!( + "[launch_flutter_emulator] Booting iOS Simulator: {}", + emulator_id + ); + let status = Command::new("xcrun") + .args(&["simctl", "boot", emulator_id]) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status() + .await; + + let _ = Command::new("open") + .args(&["-a", "Simulator"]) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status() + .await; + + return match status { + Ok(s) if s.success() => { + eprintln!("[launch_flutter_emulator] iOS Simulator boot succeeded"); + Ok(()) + } + _ => { + eprintln!("[launch_flutter_emulator] iOS Simulator boot failed"); + Err("Failed to boot iOS simulator".to_string()) + } + }; + } + + // 2. Android Emulator boot + let flutter_cmd = flutter_path + .map(|p| p.to_string_lossy().to_string()) + .unwrap_or_else(|| "flutter".to_string()); + + let status = make_flutter_command(&flutter_cmd) + .args(&["emulators", "--launch", emulator_id]) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status() + .await; + + match status { + Ok(s) if s.success() => { + eprintln!("[launch_flutter_emulator] Flutter emulator launch succeeded"); + Ok(()) + } + _ => { + // Fallback: spawn emulator -avd directly as background process + let mut emulator_cmd = "emulator".to_string(); + if let Ok(home) = std::env::var("HOME") { + let sdk_emulator = std::path::Path::new(&home) + .join("Library") + .join("Android") + .join("sdk") + .join("emulator") + .join("emulator"); + if sdk_emulator.exists() { + emulator_cmd = sdk_emulator.to_string_lossy().to_string(); + } + } + + eprintln!( + "[launch_flutter_emulator] Falling back to native: {} -avd {}", + emulator_cmd, emulator_id + ); + let spawn_result = Command::new(&emulator_cmd) + .args(&["-avd", emulator_id]) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .spawn(); + + match spawn_result { + Ok(_) => { + eprintln!("[launch_flutter_emulator] Fallback emulator spawn succeeded"); + Ok(()) + } + Err(e) => { + eprintln!( + "[launch_flutter_emulator] Fallback emulator spawn failed: {:?}", + e + ); + Err(format!( + "Both flutter emulator launch and fallback emulator launch failed. Fallback error: {}", + e + )) + } + } + } + } +} diff --git a/src/async_runtime/message.rs b/src/async_runtime/message.rs index 709fa763..3ad4a621 100644 --- a/src/async_runtime/message.rs +++ b/src/async_runtime/message.rs @@ -394,6 +394,13 @@ pub enum WorkerRequestPayload { ScanDartEnvironments { workspace_root: PathBuf, }, + ScanFlutterDevices { + flutter_path: Option, + }, + LaunchFlutterEmulator { + flutter_path: Option, + emulator_id: String, + }, /// Detect Python / Node / Go runtime versions for statusbar display. /// `python_binary` is the interpreter path from the selected venv (or system). DetectRuntimeVersions { @@ -773,6 +780,8 @@ pub enum WorkerResultPayload { PythonEnvironmentsDiscovered(Vec), /// Kết quả scan Dart environments. DartEnvironmentsDiscovered(Vec), + FlutterDevicesDiscovered(Vec), + FlutterEmulatorLaunched, /// Runtime version strings for statusbar display. RuntimeVersionsDetected { python_version: Option, diff --git a/src/async_runtime/mod.rs b/src/async_runtime/mod.rs index db453578..ddc6b466 100644 --- a/src/async_runtime/mod.rs +++ b/src/async_runtime/mod.rs @@ -1,4 +1,5 @@ pub mod dart_env; +pub mod flutter_device; pub mod message; pub mod python_env; pub mod scheduler; diff --git a/src/async_runtime/scheduler/ai.rs b/src/async_runtime/scheduler/ai.rs index 08b53016..8c3f5597 100644 --- a/src/async_runtime/scheduler/ai.rs +++ b/src/async_runtime/scheduler/ai.rs @@ -67,7 +67,10 @@ pub(super) async fn execute_ai_inline_request( "max_tokens": max_tokens, "stream": stream_response }); - if let Some(effort) = reasoning_effort.as_ref().filter(|effort| !effort.is_empty()) { + if let Some(effort) = reasoning_effort + .as_ref() + .filter(|effort| !effort.is_empty()) + { body["reasoning_effort"] = serde_json::Value::String(effort.clone()); } diff --git a/src/async_runtime/scheduler/dispatch.rs b/src/async_runtime/scheduler/dispatch.rs index 41fc4404..c3f7c517 100644 --- a/src/async_runtime/scheduler/dispatch.rs +++ b/src/async_runtime/scheduler/dispatch.rs @@ -382,6 +382,66 @@ pub(super) async fn dispatch_loop( continue; } + if matches!( + request.payload, + WorkerRequestPayload::ScanFlutterDevices { .. } + ) { + let flutter_path = match request.payload { + WorkerRequestPayload::ScanFlutterDevices { flutter_path } => flutter_path, + _ => unreachable!(), + }; + let worker_tx = result_tx.clone(); + let event_proxy = event_proxy.clone(); + tokio::spawn(async move { + let devices = + crate::async_runtime::flutter_device::scan_flutter_devices(flutter_path).await; + emit_message_and_wake( + &worker_tx, + &event_proxy, + WorkerMessage::Result(WorkerResult { + request_id: request.request_id, + revision_id: request.revision_id, + topic: request.topic, + payload: WorkerResultPayload::FlutterDevicesDiscovered(devices), + }), + ); + }); + continue; + } + + if matches!( + request.payload, + WorkerRequestPayload::LaunchFlutterEmulator { .. } + ) { + let (flutter_path, emulator_id) = match request.payload { + WorkerRequestPayload::LaunchFlutterEmulator { + flutter_path, + emulator_id, + } => (flutter_path, emulator_id), + _ => unreachable!(), + }; + let worker_tx = result_tx.clone(); + let event_proxy = event_proxy.clone(); + tokio::spawn(async move { + let _ = crate::async_runtime::flutter_device::launch_flutter_emulator( + flutter_path, + &emulator_id, + ) + .await; + emit_message_and_wake( + &worker_tx, + &event_proxy, + WorkerMessage::Result(WorkerResult { + request_id: request.request_id, + revision_id: request.revision_id, + topic: request.topic, + payload: WorkerResultPayload::FlutterEmulatorLaunched, + }), + ); + }); + continue; + } + if matches!( request.payload, WorkerRequestPayload::DetectRuntimeVersions { .. } @@ -426,7 +486,10 @@ pub(super) async fn dispatch_loop( continue; } - if matches!(request.payload, WorkerRequestPayload::ReadExternalFiles { .. }) { + if matches!( + request.payload, + WorkerRequestPayload::ReadExternalFiles { .. } + ) { let WorkerRequestPayload::ReadExternalFiles { paths } = request.payload else { unreachable!() }; @@ -460,7 +523,10 @@ pub(super) async fn dispatch_loop( continue; } - if matches!(request.payload, WorkerRequestPayload::RescanWorkspace { .. }) { + if matches!( + request.payload, + WorkerRequestPayload::RescanWorkspace { .. } + ) { let WorkerRequestPayload::RescanWorkspace { root_path, ignore_rules, diff --git a/src/async_runtime/scheduler/runtime.rs b/src/async_runtime/scheduler/runtime.rs index 1ed18041..4907eb19 100644 --- a/src/async_runtime/scheduler/runtime.rs +++ b/src/async_runtime/scheduler/runtime.rs @@ -82,6 +82,10 @@ impl AsyncScheduler { Ok(request) } + + pub fn enter(&self) -> tokio::runtime::EnterGuard<'_> { + self._runtime.handle().enter() + } } pub(super) fn build_worker_runtime() -> Result { diff --git a/src/async_runtime/scheduler/tests.rs b/src/async_runtime/scheduler/tests.rs index 99195cff..a9e0c1ca 100644 --- a/src/async_runtime/scheduler/tests.rs +++ b/src/async_runtime/scheduler/tests.rs @@ -5,7 +5,9 @@ use notify::{Event as NotifyEvent, EventKind as NotifyEventKind, event::ModifyKi use crate::async_runtime::{ message::{FileSystemChangeKind, FileSystemEvent}, scheduler::{ - file_watch::{extend_unique_file_events, file_watch_restart_backoff, normalize_notify_event}, + file_watch::{ + extend_unique_file_events, file_watch_restart_backoff, normalize_notify_event, + }, fzf::{build_file_preview_lines, build_fzf_find_file_script, build_fzf_live_grep_script}, git::parse_git_blame_summary, runtime::build_worker_runtime, diff --git a/src/config/ai_config.rs b/src/config/ai_config.rs index e7eb7b93..7355bcdb 100644 --- a/src/config/ai_config.rs +++ b/src/config/ai_config.rs @@ -91,8 +91,7 @@ impl AiConfig { pub fn set_inline_api_key(&mut self, value: String) -> Result<(), String> { let trimmed = value.trim(); - self.inline_mut().provider.api_key = - (!trimmed.is_empty()).then(|| trimmed.to_string()); + self.inline_mut().provider.api_key = (!trimmed.is_empty()).then(|| trimmed.to_string()); self.save_user_override() } diff --git a/src/config/keymap_loader.rs b/src/config/keymap_loader.rs index 65d6ebdb..eea2e4d2 100644 --- a/src/config/keymap_loader.rs +++ b/src/config/keymap_loader.rs @@ -154,8 +154,8 @@ mod tests { #[test] fn default_keymap_has_no_unknown_commands() { - let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) - .join("config/keymaps/default.toml"); + let path = + std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("config/keymaps/default.toml"); let content = std::fs::read_to_string(&path).expect("read default keymap"); let file: KeymapFile = toml::from_str(&content).expect("parse default keymap"); let unknown: Vec = file diff --git a/src/core/command_dispatch/mod.rs b/src/core/command_dispatch/mod.rs index 519d7933..b6d2a3d3 100644 --- a/src/core/command_dispatch/mod.rs +++ b/src/core/command_dispatch/mod.rs @@ -334,7 +334,22 @@ fn dispatch_command_with_clipboard_once( format!("Dispatch: resize command {command:?} handled by event loop"), false, ), - Command::SaveFile + Command::DebugStart + | Command::DebugStop + | Command::DebugContinue + | Command::DebugStepOver + | Command::DebugStepInto + | Command::DebugStepOut + | Command::DebugToggleBreakpoint + | Command::DebugWatchAdd + | Command::DebugWatchRemove + | Command::DebugGotoFrame + | Command::FocusDap + | Command::DapToggleExpand + | Command::FlutterDevices + | Command::FlutterHotReload + | Command::FlutterHotRestart + | Command::SaveFile | Command::OpenFile(_) | Command::NewInstance | Command::OpenFolder diff --git a/src/core/command_dispatch/navigation.rs b/src/core/command_dispatch/navigation.rs index c68f43b1..e3fafd2b 100644 --- a/src/core/command_dispatch/navigation.rs +++ b/src/core/command_dispatch/navigation.rs @@ -188,9 +188,7 @@ pub(super) fn dispatch(ctx: &mut DispatchCtx<'_, '_, '_>, command: Command) -> D false, ), Command::MoveFindChar(kind, target) => { - let changed = ctx - .app_state - .find_char_motion_and_highlight(kind, target); + let changed = ctx.app_state.find_char_motion_and_highlight(kind, target); DispatchReport::success( if changed { format!("Dispatch: find char '{target}' ({kind:?})") diff --git a/src/core/command_dispatch/palette.rs b/src/core/command_dispatch/palette.rs index ee211e85..40cbb602 100644 --- a/src/core/command_dispatch/palette.rs +++ b/src/core/command_dispatch/palette.rs @@ -596,6 +596,11 @@ fn confirm_selection(ctx: &mut DispatchCtx<'_, '_, '_>) -> DispatchReport { true, false, ), + CommandPaletteAction::SelectFlutterDevice { .. } => DispatchReport::success_with_flags( + "Dispatch: flutter device selected (handled by AppShell)".to_string(), + true, + false, + ), } } diff --git a/src/core/command_dispatch/session.rs b/src/core/command_dispatch/session.rs index b3e84299..d2cb526e 100644 --- a/src/core/command_dispatch/session.rs +++ b/src/core/command_dispatch/session.rs @@ -81,6 +81,21 @@ pub(super) fn dispatch(ctx: &mut DispatchCtx<'_, '_, '_>, command: Command) -> D } Command::ToggleTerminal => toggle_terminal(ctx), Command::ToggleBottomDock + | Command::DebugStart + | Command::DebugStop + | Command::DebugContinue + | Command::DebugStepOver + | Command::DebugStepInto + | Command::DebugStepOut + | Command::DebugToggleBreakpoint + | Command::DebugWatchAdd + | Command::DebugWatchRemove + | Command::DebugGotoFrame + | Command::FocusDap + | Command::DapToggleExpand + | Command::FlutterDevices + | Command::FlutterHotReload + | Command::FlutterHotRestart | Command::ToggleLeftDock | Command::GitOpenLazygit | Command::GitOpenLazydocker diff --git a/src/core/command_ids.rs b/src/core/command_ids.rs index ec203797..428b0d9d 100644 --- a/src/core/command_ids.rs +++ b/src/core/command_ids.rs @@ -201,6 +201,20 @@ pub const FOCUS_EDITOR: &str = "app.focus_editor"; pub const FOCUS_EXPLORER: &str = "app.focus_explorer"; pub const FOCUS_TERMINAL: &str = "app.focus_terminal"; pub const FOCUS_INSPECTOR: &str = "app.focus_inspector"; +pub const FOCUS_DAP: &str = "app.focus_dap"; +pub const DAP_TOGGLE_EXPAND: &str = "dap.toggle_expand"; +pub const DEBUG_START: &str = "dap.continue"; +pub const DEBUG_STOP: &str = "dap.stop"; +pub const DEBUG_STEP_OVER: &str = "dap.step_over"; +pub const DEBUG_STEP_INTO: &str = "dap.step_into"; +pub const DEBUG_STEP_OUT: &str = "dap.step_out"; +pub const DEBUG_TOGGLE_BREAKPOINT: &str = "dap.toggle_breakpoint"; +pub const DEBUG_WATCH_ADD: &str = "dap.watch_add"; +pub const DEBUG_WATCH_REMOVE: &str = "dap.watch_remove"; +pub const DEBUG_GOTO_FRAME: &str = "dap.goto_frame"; +pub const FLUTTER_DEVICES: &str = "flutter.devices"; +pub const FLUTTER_HOT_RELOAD: &str = "flutter.hot_reload"; +pub const FLUTTER_HOT_RESTART: &str = "flutter.hot_restart"; pub const FOCUS_LEFT: &str = "app.focus_left"; pub const FOCUS_RIGHT: &str = "app.focus_right"; pub const FOCUS_UP: &str = "app.focus_up"; @@ -487,6 +501,20 @@ pub const ALL_IDS: &[&str] = &[ OPEN_HELP, MARKDOWN_PREVIEW_SCROLL_TOP, MARKDOWN_PREVIEW_SCROLL_BOTTOM, + FOCUS_DAP, + DAP_TOGGLE_EXPAND, + DEBUG_START, + DEBUG_STOP, + DEBUG_STEP_OVER, + DEBUG_STEP_INTO, + DEBUG_STEP_OUT, + DEBUG_TOGGLE_BREAKPOINT, + DEBUG_WATCH_ADD, + DEBUG_WATCH_REMOVE, + DEBUG_GOTO_FRAME, + FLUTTER_DEVICES, + FLUTTER_HOT_RELOAD, + FLUTTER_HOT_RESTART, ]; /// A command id is valid if it is registered in ALL_IDS or resolvable by @@ -652,6 +680,20 @@ pub fn parse(id: &str, open_file_path: Option<&std::path::Path>) -> Option Some(Command::FocusExplorer), FOCUS_TERMINAL => Some(Command::FocusTerminal), FOCUS_INSPECTOR => Some(Command::FocusInspector), + FOCUS_DAP => Some(Command::FocusDap), + DAP_TOGGLE_EXPAND => Some(Command::DapToggleExpand), + DEBUG_START => Some(Command::DebugStart), + DEBUG_STOP => Some(Command::DebugStop), + DEBUG_STEP_OVER => Some(Command::DebugStepOver), + DEBUG_STEP_INTO => Some(Command::DebugStepInto), + DEBUG_STEP_OUT => Some(Command::DebugStepOut), + DEBUG_TOGGLE_BREAKPOINT => Some(Command::DebugToggleBreakpoint), + DEBUG_WATCH_ADD => Some(Command::DebugWatchAdd), + DEBUG_WATCH_REMOVE => Some(Command::DebugWatchRemove), + DEBUG_GOTO_FRAME => Some(Command::DebugGotoFrame), + FLUTTER_DEVICES => Some(Command::FlutterDevices), + FLUTTER_HOT_RELOAD => Some(Command::FlutterHotReload), + FLUTTER_HOT_RESTART => Some(Command::FlutterHotRestart), FOCUS_LEFT => Some(Command::FocusLeft), FOCUS_RIGHT => Some(Command::FocusRight), FOCUS_UP => Some(Command::FocusUp), diff --git a/src/core/commands.rs b/src/core/commands.rs index b4b685ce..1a0fec03 100644 --- a/src/core/commands.rs +++ b/src/core/commands.rs @@ -122,6 +122,23 @@ pub enum Command { SearchWordUnderCursor, ClearSearchHighlights, + // ── Debugger (DAP) & Flutter ─────────────────────────────────────────────── + DebugStart, + DebugStop, + DebugContinue, + DebugStepOver, + DebugStepInto, + DebugStepOut, + DebugToggleBreakpoint, + DebugWatchAdd, + DebugWatchRemove, + DebugGotoFrame, + FocusDap, + DapToggleExpand, + FlutterDevices, + FlutterHotReload, + FlutterHotRestart, + // ── File & palette ───────────────────────────────────────────────────────── SaveFile, OpenFile(PathBuf), diff --git a/src/dap/client.rs b/src/dap/client.rs new file mode 100644 index 00000000..bc13a682 --- /dev/null +++ b/src/dap/client.rs @@ -0,0 +1,365 @@ +use std::collections::HashMap; +use std::path::PathBuf; +use std::process::Stdio; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::{Arc, Mutex}; +use tokio::io::{AsyncRead, AsyncReadExt, AsyncWriteExt, BufReader}; +use tokio::process::{Child, ChildStdin, Command}; +use tokio::sync::{mpsc, oneshot}; + +use crate::dap::types::{Event, Request, Response}; + +pub struct DapClient { + _child: Child, + stdin: tokio::sync::Mutex, + next_seq: AtomicU64, + pending_requests: Arc>>>, +} + +impl DapClient { + pub fn launch( + program: &str, + args: &[String], + working_dir: Option, + event_tx: mpsc::UnboundedSender, + ) -> Result, std::io::Error> { + Self::launch_with_env(program, args, working_dir, event_tx, None) + } + + pub fn launch_with_env( + program: &str, + args: &[String], + working_dir: Option, + event_tx: mpsc::UnboundedSender, + env_vars: Option>, + ) -> Result, std::io::Error> { + eprintln!( + "[DAP LOG] [DAP Client] DapClient::launch called for program: {}, args: {:?}", + program, args + ); + let path_buf = std::path::Path::new(program); + let exists = if path_buf.is_absolute() || path_buf.components().count() > 1 { + path_buf.exists() + } else if let Ok(path) = std::env::var("PATH") { + let mut found = false; + for dir in std::env::split_paths(&path) { + let joined = dir.join(program); + eprintln!( + "[DAP LOG] exists check: program={}, checking joined={:?}, exists={}", + program, + joined, + joined.exists() + ); + if joined.exists() { + found = true; + break; + } + } + found + } else { + false + }; + if !exists { + return Err(std::io::Error::new( + std::io::ErrorKind::NotFound, + format!( + "Binary '{}' not found in PATH or at specified path", + program + ), + )); + } + + let mut cmd = Command::new(program); + cmd.args(args); + cmd.stdin(Stdio::piped()); + cmd.stdout(Stdio::piped()); + cmd.stderr(Stdio::null()); + + // Set environment variables if provided (for FVM support) + if let Some(env) = env_vars { + for (key, value) in env { + cmd.env(&key, &value); + } + } + + if let Some(wd) = working_dir { + cmd.current_dir(wd); + } + + let mut child = cmd.spawn()?; + let stdin = child.stdin.take().ok_or_else(|| { + std::io::Error::new(std::io::ErrorKind::Other, "Failed to capture stdin") + })?; + let stdout = child.stdout.take().ok_or_else(|| { + std::io::Error::new(std::io::ErrorKind::Other, "Failed to capture stdout") + })?; + + let pending_requests: Arc>>> = + Arc::new(Mutex::new(HashMap::new())); + let pending_requests_clone = pending_requests.clone(); + + // Spawn background stdout reader thread + tokio::spawn(async move { + let mut reader = BufReader::new(stdout); + let mut buffer = Vec::new(); + + loop { + match read_message(&mut reader, &mut buffer).await { + Ok(Some(raw_json)) => { + if let Ok(value) = serde_json::from_str::(&raw_json) { + let msg_type = value.get("type").and_then(|v| v.as_str()).unwrap_or(""); + match msg_type { + "response" => { + if let Ok(resp) = serde_json::from_str::(&raw_json) { + let mut guard = pending_requests_clone.lock().unwrap(); + if let Some(tx) = guard.remove(&resp.request_seq) { + let _ = tx.send(resp); + } + } + } + "event" => { + if let Ok(event) = serde_json::from_str::(&raw_json) { + let _ = event_tx.send(event); + } + } + _ => {} + } + } + } + Ok(None) => { + // EOF reached + break; + } + Err(err) => { + eprintln!("[DapClient] Error reading stdout message: {:?}", err); + break; + } + } + } + }); + + Ok(Arc::new(Self { + _child: child, + stdin: tokio::sync::Mutex::new(stdin), + next_seq: AtomicU64::new(1), + pending_requests, + })) + } + + pub async fn send_request( + &self, + command: &str, + arguments: Option, + ) -> Result { + let seq = self.next_seq.fetch_add(1, Ordering::SeqCst); + let req = Request { + seq, + message_type: "request".to_string(), + command: command.to_string(), + arguments, + }; + + let raw_json = serde_json::to_string(&req) + .map_err(|e| format!("Failed to serialize request: {}", e))?; + + let (tx, rx) = oneshot::channel(); + { + let mut guard = self.pending_requests.lock().unwrap(); + guard.insert(seq, tx); + } + + let payload = format!("Content-Length: {}\r\n\r\n{}", raw_json.len(), raw_json); + { + let mut stdin_guard = self.stdin.lock().await; + stdin_guard + .write_all(payload.as_bytes()) + .await + .map_err(|e| format!("Failed to write to stdin: {}", e))?; + stdin_guard + .flush() + .await + .map_err(|e| format!("Failed to flush stdin: {}", e))?; + } + + rx.await + .map_err(|_| "Debug adapter disconnected".to_string()) + } +} + +// ── Read DAP Protocol Header & Body ────────────────────────────────────────── + +async fn read_message( + reader: &mut R, + buffer: &mut Vec, +) -> std::io::Result> { + let mut content_length: Option = None; + + loop { + // Read header line by line + let mut line = String::new(); + let mut header_bytes = [0u8; 1]; + + loop { + let bytes_read = reader.read(&mut header_bytes).await?; + if bytes_read == 0 { + return Ok(None); // EOF + } + let c = header_bytes[0] as char; + line.push(c); + if line.ends_with("\r\n") { + break; + } + } + + if line == "\r\n" { + // End of headers, break to read body + break; + } + + if line.starts_with("Content-Length:") { + let parts: Vec<&str> = line.split(':').collect(); + if parts.len() == 2 { + if let Ok(len) = parts[1].trim().parse::() { + content_length = Some(len); + } + } + } + } + + let len = content_length.ok_or_else(|| { + std::io::Error::new( + std::io::ErrorKind::InvalidData, + "Missing Content-Length header", + ) + })?; + + buffer.resize(len, 0); + reader.read_exact(buffer).await?; + + let json_str = String::from_utf8(buffer.clone()).map_err(|e| { + std::io::Error::new( + std::io::ErrorKind::InvalidData, + format!("Invalid UTF-8: {}", e), + ) + })?; + + Ok(Some(json_str)) +} + +#[cfg(test)] +mod tests { + use super::*; + use tokio::io::BufReader; + + fn make_dap_message(json: &str) -> Vec { + let header = format!("Content-Length: {}\r\n\r\n", json.len()); + let mut bytes = header.into_bytes(); + bytes.extend_from_slice(json.as_bytes()); + bytes + } + + #[tokio::test] + async fn read_message_parses_valid_dap_frame() { + let json = r#"{"seq":1,"type":"event","event":"initialized"}"#; + let data = make_dap_message(json); + let mut reader = BufReader::new(&data[..]); + let mut buffer = Vec::new(); + + let result = read_message(&mut reader, &mut buffer).await.unwrap(); + assert!(result.is_some()); + assert_eq!(result.unwrap(), json); + } + + #[tokio::test] + async fn read_message_returns_none_on_eof() { + let data: &[u8] = &[]; + let mut reader = BufReader::new(data); + let mut buffer = Vec::new(); + + let result = read_message(&mut reader, &mut buffer).await.unwrap(); + assert!(result.is_none()); + } + + #[tokio::test] + async fn read_message_errors_on_missing_content_length() { + // Header with no Content-Length + let data = b"\r\n"; + let mut reader = BufReader::new(&data[..]); + let mut buffer = Vec::new(); + + let result = read_message(&mut reader, &mut buffer).await; + assert!(result.is_err()); + } + + #[tokio::test] + async fn read_message_parses_multiple_frames_sequentially() { + let json1 = r#"{"seq":1,"type":"event","event":"initialized"}"#; + let json2 = + r#"{"seq":2,"type":"response","request_seq":0,"success":true,"command":"initialize"}"#; + let mut data = make_dap_message(json1); + data.extend_from_slice(&make_dap_message(json2)); + + let mut reader = BufReader::new(&data[..]); + let mut buffer = Vec::new(); + + let r1 = read_message(&mut reader, &mut buffer) + .await + .unwrap() + .unwrap(); + assert_eq!(r1, json1); + + let r2 = read_message(&mut reader, &mut buffer) + .await + .unwrap() + .unwrap(); + assert_eq!(r2, json2); + } + + #[tokio::test] + async fn read_message_handles_empty_body() { + let json = "{}"; + let data = make_dap_message(json); + let mut reader = BufReader::new(&data[..]); + let mut buffer = Vec::new(); + + let result = read_message(&mut reader, &mut buffer).await.unwrap(); + assert_eq!(result.unwrap(), "{}"); + } + + #[tokio::test] + async fn read_message_handles_unicode_content() { + let json = r#"{"output":"Xin chào thế giới 🌍"}"#; + let data = make_dap_message(json); + let mut reader = BufReader::new(&data[..]); + let mut buffer = Vec::new(); + + let result = read_message(&mut reader, &mut buffer).await.unwrap(); + assert_eq!(result.unwrap(), json); + } + + #[tokio::test] + async fn read_message_reuses_buffer_across_calls() { + let json1 = r#"{"short":true}"#; + let json2 = + r#"{"a_much_longer_field_name":"with a longer value that exceeds the first buffer"}"#; + let mut data = make_dap_message(json1); + data.extend_from_slice(&make_dap_message(json2)); + + let mut reader = BufReader::new(&data[..]); + let mut buffer = Vec::new(); + + let r1 = read_message(&mut reader, &mut buffer) + .await + .unwrap() + .unwrap(); + assert_eq!(r1, json1); + + let r2 = read_message(&mut reader, &mut buffer) + .await + .unwrap() + .unwrap(); + assert_eq!(r2, json2); + // Buffer should have been resized for the larger message + assert!(buffer.len() >= json2.len()); + } +} diff --git a/src/dap/launch_config.rs b/src/dap/launch_config.rs new file mode 100644 index 00000000..6d8c2b61 --- /dev/null +++ b/src/dap/launch_config.rs @@ -0,0 +1,271 @@ +use serde::Deserialize; +use std::path::{Path, PathBuf}; + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct LaunchJson { + pub version: Option, + #[serde(default)] + pub configurations: Vec, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct LaunchConfiguration { + pub name: String, + #[serde(rename = "type")] + pub config_type: String, + pub request: String, + #[serde(default)] + pub program: Option, + #[serde(default)] + pub args: Option>, + #[serde(default)] + pub cwd: Option, + #[serde(default)] + pub env: Option>, + #[serde(default)] + pub tool_args: Option>, + #[serde(default)] + pub device_id: Option, + #[serde(default)] + pub flutter_mode: Option, + #[serde(default)] + pub dart_path: Option, + #[serde(flatten)] + pub extra: std::collections::HashMap, +} + +#[derive(Debug, Clone)] +pub struct ResolvedLaunchConfig { + pub name: String, + pub config_type: String, + pub request: String, + pub program: String, + pub args: Vec, + pub cwd: Option, + pub env: std::collections::HashMap, + pub tool_args: Vec, + pub device_id: Option, +} + +impl LaunchConfiguration { + pub fn resolve(&self, workspace_root: &Path) -> ResolvedLaunchConfig { + let program = self.program.clone().unwrap_or_default(); + let cwd = self.cwd.as_ref().map(|c| { + let p = PathBuf::from(c); + if p.is_absolute() { + p + } else { + workspace_root.join(p) + } + }); + ResolvedLaunchConfig { + name: self.name.clone(), + config_type: self.config_type.clone(), + request: self.request.clone(), + program, + args: self.args.clone().unwrap_or_default(), + cwd, + env: self.env.clone().unwrap_or_default(), + tool_args: self.tool_args.clone().unwrap_or_default(), + device_id: self.device_id.clone(), + } + } +} + +pub fn find_launch_json(workspace_root: &Path) -> Option { + let candidates = [ + workspace_root.join(".vscode").join("launch.json"), + workspace_root.join(".zed").join("debug.json"), + workspace_root.join(".cursor").join("launch.json"), + ]; + candidates.into_iter().find(|p| p.exists()) +} + +pub fn load_launch_json(workspace_root: &Path) -> Option { + let path = find_launch_json(workspace_root)?; + let content = std::fs::read_to_string(&path).ok()?; + // VS Code launch.json may contain comments (JSONC), strip them. + let cleaned = strip_json_comments(&content); + serde_json::from_str(&cleaned).ok() +} + +fn strip_json_comments(json: &str) -> String { + let mut result = String::with_capacity(json.len()); + let mut in_string = false; + let mut escape_next = false; + let chars: Vec = json.chars().collect(); + let mut i = 0; + + while i < chars.len() { + let c = chars[i]; + + if escape_next { + result.push(c); + escape_next = false; + i += 1; + continue; + } + + if in_string { + if c == '\\' { + escape_next = true; + } else if c == '"' { + in_string = false; + } + result.push(c); + i += 1; + continue; + } + + if c == '"' { + in_string = true; + result.push(c); + i += 1; + continue; + } + + if c == '/' && i + 1 < chars.len() { + if chars[i + 1] == '/' { + // Line comment - skip to end of line + while i < chars.len() && chars[i] != '\n' { + i += 1; + } + continue; + } else if chars[i + 1] == '*' { + // Block comment - skip to */ + i += 2; + while i + 1 < chars.len() { + if chars[i] == '*' && chars[i + 1] == '/' { + i += 2; + break; + } + i += 1; + } + continue; + } + } + + result.push(c); + i += 1; + } + + result +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parse_flutter_launch_json() { + let json = r#"{ + "version": "0.2.0", + "configurations": [ + { + "name": "Flutter", + "type": "dart", + "request": "launch", + "program": "lib/main.dart", + "toolArgs": ["-d", "chrome"], + "deviceId": "chrome" + } + ] + }"#; + let config: LaunchJson = serde_json::from_str(json).unwrap(); + assert_eq!(config.configurations.len(), 1); + assert_eq!(config.configurations[0].name, "Flutter"); + assert_eq!(config.configurations[0].config_type, "dart"); + assert_eq!(config.configurations[0].request, "launch"); + assert_eq!( + config.configurations[0].program.as_deref(), + Some("lib/main.dart") + ); + } + + #[test] + fn parse_rust_launch_json() { + let json = r#"{ + "version": "0.2.0", + "configurations": [ + { + "name": "Debug", + "type": "lldb", + "request": "launch", + "program": "${workspaceFolder}/target/debug/myapp" + } + ] + }"#; + let config: LaunchJson = serde_json::from_str(json).unwrap(); + assert_eq!(config.configurations[0].config_type, "lldb"); + } + + #[test] + fn strip_single_line_comments() { + let json = r#"{ + // comment + "key": "value" + }"#; + let cleaned = strip_json_comments(json); + let parsed: serde_json::Value = serde_json::from_str(&cleaned).unwrap(); + assert_eq!(parsed["key"], "value"); + } + + #[test] + fn strip_block_comments() { + let json = r#"{ + /* block + comment */ + "key": "value" + }"#; + let cleaned = strip_json_comments(json); + let parsed: serde_json::Value = serde_json::from_str(&cleaned).unwrap(); + assert_eq!(parsed["key"], "value"); + } + + #[test] + fn strip_comments_preserves_strings() { + let json = r#"{ + "url": "http://example.com//not-a-comment" + }"#; + let cleaned = strip_json_comments(json); + let parsed: serde_json::Value = serde_json::from_str(&cleaned).unwrap(); + assert_eq!(parsed["url"], "http://example.com//not-a-comment"); + } + + #[test] + fn resolve_config_with_relative_cwd() { + let config = LaunchConfiguration { + name: "Test".to_string(), + config_type: "dart".to_string(), + request: "launch".to_string(), + program: Some("lib/main.dart".to_string()), + args: None, + cwd: Some("my_project".to_string()), + env: None, + tool_args: None, + device_id: None, + flutter_mode: None, + dart_path: None, + extra: std::collections::HashMap::new(), + }; + let resolved = config.resolve(Path::new("/workspace")); + assert_eq!(resolved.cwd, Some(PathBuf::from("/workspace/my_project"))); + } + + #[test] + fn multiple_configurations() { + let json = r#"{ + "version": "0.2.0", + "configurations": [ + { "name": "App", "type": "dart", "request": "launch", "program": "lib/main.dart" }, + { "name": "Tests", "type": "dart", "request": "launch", "program": "test/" }, + { "name": "Profile", "type": "dart", "request": "launch", "program": "lib/main.dart", "flutterMode": "profile" } + ] + }"#; + let config: LaunchJson = serde_json::from_str(json).unwrap(); + assert_eq!(config.configurations.len(), 3); + assert_eq!(config.configurations[2].name, "Profile"); + } +} diff --git a/src/dap/mod.rs b/src/dap/mod.rs new file mode 100644 index 00000000..1664ed11 --- /dev/null +++ b/src/dap/mod.rs @@ -0,0 +1,8 @@ +pub mod client; +pub mod launch_config; +pub mod session; +pub mod types; + +pub use client::DapClient; +pub use session::DapSession; +pub use types::{Breakpoint, Event, Request, Response, StackFrame, Thread, Variable}; diff --git a/src/dap/session.rs b/src/dap/session.rs new file mode 100644 index 00000000..7b8db82b --- /dev/null +++ b/src/dap/session.rs @@ -0,0 +1,366 @@ +use serde_json::json; +use std::path::Path; +use std::sync::{Arc, Mutex}; + +use crate::dap::client::DapClient; +use crate::dap::types::{ + Breakpoint as DapBreakpoint, Scope as DapScope, SourceBreakpoint, StackFrame as DapStackFrame, + Variable as DapVariable, +}; +use crate::workbench::debug_state::{ + Breakpoint as EditorBreakpoint, DebugSharedState, DebugVariable, SourceLocation, + StackFrame as EditorStackFrame, +}; + +pub struct DapSession { + pub client: Arc, + pub state: Arc>, + pub active_thread_id: Arc>>, +} + +impl DapSession { + pub fn new(client: Arc) -> Self { + Self { + client, + state: Arc::new(tokio::sync::Mutex::new(DebugSharedState::default())), + active_thread_id: Arc::new(Mutex::new(None)), + } + } + + pub async fn initialize(&self, adapter_id: &str) -> Result<(), String> { + let args = json!({ + "clientID": "netherize", + "clientName": "Netherize Editor", + "adapterID": adapter_id, + "linesStartAt1": true, + "columnsStartAt1": true, + "pathFormat": "path" + }); + + self.client.send_request("initialize", Some(args)).await?; + Ok(()) + } + + pub async fn launch_flutter(&self, program: &str, device_id: &str) -> Result<(), String> { + let args = json!({ + "request": "launch", + "program": program, + "toolArgs": ["-d", device_id], + "noDebug": false + }); + + self.client.send_request("launch", Some(args)).await?; + // Standard configurationDone to let the adapter know initialization is finished + self.client.send_request("configurationDone", None).await?; + + let mut state_guard = self.state.lock().await; + state_guard.paused = false; + state_guard + .console_messages + .push("Flutter application launching...".to_string()); + + Ok(()) + } + + pub async fn set_breakpoints(&self, path: &Path, lines: &[usize]) -> Result<(), String> { + let source_path = path.to_string_lossy().to_string(); + let breakpoints: Vec = lines + .iter() + .map(|&l| SourceBreakpoint { + line: (l + 1) as i64, // Convert 0-indexed to 1-indexed + column: None, + }) + .collect(); + + let args = json!({ + "source": { + "name": path.file_name().map(|f| f.to_string_lossy().to_string()), + "path": source_path, + }, + "breakpoints": breakpoints + }); + + let resp = self + .client + .send_request("setBreakpoints", Some(args)) + .await?; + if resp.success { + if let Some(body) = resp.body { + if let Ok(dap_bps) = + serde_json::from_value::>(body["breakpoints"].clone()) + { + let mut state_guard = self.state.lock().await; + state_guard.breakpoints.clear(); + for (i, dap_bp) in dap_bps.into_iter().enumerate() { + if dap_bp.verified { + if let Some(line) = dap_bp.line { + state_guard.breakpoints.push(EditorBreakpoint { + id: dap_bp.id.unwrap_or(i as i64) as u64, + location: SourceLocation { + line: (line - 1) as usize, // 1-indexed to 0-indexed + column: 0, + }, + enabled: true, + path: path + .canonicalize() + .unwrap_or_else(|_| path.to_path_buf()), + }); + } + } + } + } + } + } + Ok(()) + } + + pub async fn resume(&self) -> Result<(), String> { + let thread_id = { *self.active_thread_id.lock().unwrap() }; + if let Some(tid) = thread_id { + let args = json!({ "threadId": tid }); + self.client.send_request("continue", Some(args)).await?; + let mut state_guard = self.state.lock().await; + state_guard.paused = false; + state_guard.execution_location = None; + } + Ok(()) + } + + pub async fn step_over(&self) -> Result<(), String> { + let thread_id = { *self.active_thread_id.lock().unwrap() }; + if let Some(tid) = thread_id { + let args = json!({ "threadId": tid }); + self.client.send_request("next", Some(args)).await?; + } + Ok(()) + } + + pub async fn step_into(&self) -> Result<(), String> { + let thread_id = { *self.active_thread_id.lock().unwrap() }; + if let Some(tid) = thread_id { + let args = json!({ "threadId": tid }); + self.client.send_request("stepIn", Some(args)).await?; + } + Ok(()) + } + + pub async fn step_out(&self) -> Result<(), String> { + let thread_id = { *self.active_thread_id.lock().unwrap() }; + if let Some(tid) = thread_id { + let args = json!({ "threadId": tid }); + self.client.send_request("stepOut", Some(args)).await?; + } + Ok(()) + } + + pub async fn stop(&self) -> Result<(), String> { + self.client.send_request("disconnect", None).await?; + let mut state_guard = self.state.lock().await; + state_guard.paused = true; + state_guard.execution_location = None; + state_guard + .console_messages + .push("Debugger session stopped.".to_string()); + Ok(()) + } + + pub async fn hot_reload(&self) -> Result<(), String> { + let args = json!({ + "request": "hotReload" + }); + self.client + .send_request("customRequest", Some(args)) + .await?; + let mut state_guard = self.state.lock().await; + state_guard + .console_messages + .push("⚡ Hot Reload triggered...".to_string()); + Ok(()) + } + + pub async fn hot_restart(&self) -> Result<(), String> { + let args = json!({ + "request": "hotRestart" + }); + self.client + .send_request("customRequest", Some(args)) + .await?; + let mut state_guard = self.state.lock().await; + state_guard + .console_messages + .push("↻ Hot Restart triggered...".to_string()); + Ok(()) + } + + pub async fn evaluate_watch_expressions(&self) -> Result<(), String> { + let expressions: Vec = { + let state_guard = self.state.lock().await; + state_guard + .watch + .iter() + .map(|w| w.expression.clone()) + .collect() + }; + + let mut results = Vec::new(); + for expr in expressions { + let args = json!({ + "expression": expr, + "context": "watch" + }); + if let Ok(resp) = self.client.send_request("evaluate", Some(args)).await { + if resp.success { + if let Some(body) = resp.body { + let value = body + .get("result") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(); + results.push((expr, value)); + } + } else { + results.push((expr, "".to_string())); + } + } + } + + let mut state_guard = self.state.lock().await; + state_guard.update_watch_values(results); + Ok(()) + } + + // ── Update suspended state when paused ───────────────────────────────────── + + pub async fn update_suspended_state(&self, thread_id: i64) -> Result<(), String> { + { + let mut tid_guard = self.active_thread_id.lock().unwrap(); + *tid_guard = Some(thread_id); + } + + // 1. Fetch Call Stack + let args = json!({ "threadId": thread_id }); + let resp = self.client.send_request("stackTrace", Some(args)).await?; + let mut call_stack = Vec::new(); + let mut current_loc = None; + let mut top_frame_id = None; + + if resp.success + && let Some(body) = resp.body + { + if let Ok(frames) = + serde_json::from_value::>(body["stackFrames"].clone()) + { + for (idx, frame) in frames.into_iter().enumerate() { + let line = (frame.line - 1).max(0) as usize; // 1-indexed to 0-indexed + let col = (frame.column - 1).max(0) as usize; + + let file_label = frame + .source + .as_ref() + .and_then(|s| s.path.as_ref()) + .and_then(|p| Path::new(p).file_name()) + .map(|n| n.to_string_lossy().to_string()) + .unwrap_or_else(|| "unknown".to_string()); + + let path = frame + .source + .as_ref() + .and_then(|s| s.path.as_ref()) + .map(Path::new) + .map(|p| p.to_path_buf()) + .unwrap_or_default(); + + if idx == 0 { + top_frame_id = Some(frame.id); + current_loc = Some(SourceLocation { line, column: col }); + } + + call_stack.push(EditorStackFrame { + function: format!("{} ({})", frame.name, file_label), + location: SourceLocation { line, column: col }, + path, + }); + } + } + } + + // 2. Fetch Variables for the Top Frame + let mut variables = Vec::new(); + if let Some(frame_id) = top_frame_id { + let args = json!({ "frameId": frame_id }); + let resp = self.client.send_request("scopes", Some(args)).await?; + if resp.success + && let Some(body) = resp.body + { + if let Ok(scopes) = serde_json::from_value::>(body["scopes"].clone()) + { + // Iterate scopes (usually Local, Global) + for scope in scopes { + if scope.variables_reference > 0 { + let var_args = + json!({ "variablesReference": scope.variables_reference }); + let var_resp = self + .client + .send_request("variables", Some(var_args)) + .await?; + if var_resp.success + && let Some(var_body) = var_resp.body + { + if let Ok(dap_vars) = serde_json::from_value::>( + var_body["variables"].clone(), + ) { + for d_var in dap_vars { + variables.push(self.fetch_variable_tree(d_var).await); + } + } + } + } + } + } + } + } + + // 3. Update active state + { + let mut state_guard = self.state.lock().await; + state_guard.paused = true; + state_guard.execution_location = current_loc; + state_guard.call_stack = call_stack; + state_guard.variables = variables; + } + + Ok(()) + } + + // Lazily evaluate/fetch nested object properties (children) + async fn fetch_variable_tree(&self, var: DapVariable) -> DebugVariable { + let mut children = Vec::new(); + if var.variables_reference > 0 { + let args = json!({ "variablesReference": var.variables_reference }); + if let Ok(resp) = self.client.send_request("variables", Some(args)).await { + if resp.success + && let Some(body) = resp.body + { + if let Ok(dap_vars) = + serde_json::from_value::>(body["variables"].clone()) + { + for d_var in dap_vars { + // Don't recursively fetch indefinitely (fetch 1 level down) + children.push(DebugVariable { + name: d_var.name, + value: d_var.value, + children: Vec::new(), + }); + } + } + } + } + } + + DebugVariable { + name: var.name, + value: var.value, + children, + } + } +} diff --git a/src/dap/types.rs b/src/dap/types.rs new file mode 100644 index 00000000..e7f31202 --- /dev/null +++ b/src/dap/types.rs @@ -0,0 +1,439 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ProtocolMessage { + pub seq: u64, + #[serde(rename = "type")] + pub message_type: String, // "request", "response", or "event" +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Request { + pub seq: u64, + #[serde(rename = "type")] + pub message_type: String, // "request" + pub command: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub arguments: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Response { + pub seq: u64, + #[serde(rename = "type")] + pub message_type: String, // "response" + pub request_seq: u64, + pub success: bool, + pub command: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub message: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub body: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Event { + pub seq: u64, + #[serde(rename = "type")] + pub message_type: String, // "event" + pub event: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub body: Option, +} + +// ── Shared Protocol Objects ────────────────────────────────────────────────── + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Source { + pub name: Option, + pub path: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Thread { + pub id: i64, + pub name: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct StackFrame { + pub id: i64, + pub name: String, + pub source: Option, + pub line: i64, + pub column: i64, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Scope { + pub name: String, + #[serde(rename = "variablesReference")] + pub variables_reference: i64, + pub expensive: bool, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Variable { + pub name: String, + pub value: String, + #[serde(rename = "variablesReference")] + pub variables_reference: i64, + pub evaluate_name: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Breakpoint { + pub id: Option, + pub verified: bool, + pub line: Option, + pub message: Option, +} + +// ── Specific Event Bodies ──────────────────────────────────────────────────── + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct StoppedEventBody { + pub reason: String, + #[serde(rename = "threadId")] + pub thread_id: Option, + #[serde(rename = "allThreadsStopped")] + pub all_threads_stopped: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct OutputEventBody { + pub category: Option, + pub output: String, +} + +// ── Specific Request Arguments ─────────────────────────────────────────────── + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct InitializeRequestArguments { + #[serde(rename = "clientID")] + pub client_id: String, + #[serde(rename = "clientName")] + pub client_name: String, + #[serde(rename = "adapterID")] + pub adapter_id: String, + #[serde(rename = "linesStartAt1")] + pub lines_start_at_1: bool, + #[serde(rename = "columnsStartAt1")] + pub columns_start_at_1: bool, + #[serde(rename = "pathFormat")] + pub path_format: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SourceBreakpoint { + pub line: i64, + pub column: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SetBreakpointsArguments { + pub source: Source, + pub breakpoints: Vec, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn request_serializes_with_type_field() { + let req = Request { + seq: 1, + message_type: "request".to_string(), + command: "initialize".to_string(), + arguments: Some(serde_json::json!({"adapterID": "flutter"})), + }; + let json = serde_json::to_string(&req).unwrap(); + assert!(json.contains(r#""type":"request""#)); + assert!(json.contains(r#""command":"initialize""#)); + assert!(json.contains(r#""adapterID":"flutter""#)); + } + + #[test] + fn request_serializes_without_arguments_when_none() { + let req = Request { + seq: 2, + message_type: "request".to_string(), + command: "configurationDone".to_string(), + arguments: None, + }; + let json = serde_json::to_string(&req).unwrap(); + assert!(!json.contains("arguments")); + } + + #[test] + fn response_deserializes_from_json() { + let json = r#"{ + "seq": 5, + "type": "response", + "request_seq": 1, + "success": true, + "command": "initialize", + "body": {"supportsConfigurationDoneRequest": true} + }"#; + let resp: Response = serde_json::from_str(json).unwrap(); + assert_eq!(resp.seq, 5); + assert_eq!(resp.request_seq, 1); + assert!(resp.success); + assert_eq!(resp.command, "initialize"); + assert!(resp.body.is_some()); + } + + #[test] + fn response_handles_failure_with_message() { + let json = r#"{ + "seq": 6, + "type": "response", + "request_seq": 2, + "success": false, + "command": "launch", + "message": "Failed to launch" + }"#; + let resp: Response = serde_json::from_str(json).unwrap(); + assert!(!resp.success); + assert_eq!(resp.message.as_deref(), Some("Failed to launch")); + } + + #[test] + fn event_deserializes_stopped_event() { + let json = r#"{ + "seq": 10, + "type": "event", + "event": "stopped", + "body": { + "reason": "breakpoint", + "threadId": 1, + "allThreadsStopped": true + } + }"#; + let event: Event = serde_json::from_str(json).unwrap(); + assert_eq!(event.event, "stopped"); + let body: StoppedEventBody = serde_json::from_value(event.body.unwrap()).unwrap(); + assert_eq!(body.reason, "breakpoint"); + assert_eq!(body.thread_id, Some(1)); + assert_eq!(body.all_threads_stopped, Some(true)); + } + + #[test] + fn event_deserializes_output_event() { + let json = r#"{ + "seq": 11, + "type": "event", + "event": "output", + "body": { + "category": "stdout", + "output": "Hello, world!\n" + } + }"#; + let event: Event = serde_json::from_str(json).unwrap(); + let body: OutputEventBody = serde_json::from_value(event.body.unwrap()).unwrap(); + assert_eq!(body.category.as_deref(), Some("stdout")); + assert_eq!(body.output, "Hello, world!\n"); + } + + #[test] + fn event_deserializes_terminated_event() { + let json = r#"{ + "seq": 12, + "type": "event", + "event": "terminated" + }"#; + let event: Event = serde_json::from_str(json).unwrap(); + assert_eq!(event.event, "terminated"); + assert!(event.body.is_none()); + } + + #[test] + fn thread_serializes_and_deserializes() { + let thread = Thread { + id: 1, + name: "main".to_string(), + }; + let json = serde_json::to_string(&thread).unwrap(); + let deserialized: Thread = serde_json::from_str(&json).unwrap(); + assert_eq!(deserialized.id, 1); + assert_eq!(deserialized.name, "main"); + } + + #[test] + fn stack_frame_with_source_roundtrips() { + let frame = StackFrame { + id: 100, + name: "main".to_string(), + source: Some(Source { + name: Some("main.dart".to_string()), + path: Some("/lib/main.dart".to_string()), + }), + line: 42, + column: 8, + }; + let json = serde_json::to_string(&frame).unwrap(); + let deserialized: StackFrame = serde_json::from_str(&json).unwrap(); + assert_eq!(deserialized.id, 100); + assert_eq!(deserialized.line, 42); + assert_eq!(deserialized.column, 8); + assert_eq!( + deserialized.source.as_ref().unwrap().name.as_deref(), + Some("main.dart") + ); + } + + #[test] + fn stack_frame_without_source_roundtrips() { + let frame = StackFrame { + id: 200, + name: "".to_string(), + source: None, + line: 1, + column: 1, + }; + let json = serde_json::to_string(&frame).unwrap(); + assert!(json.contains("null") || !json.contains("source")); + let deserialized: StackFrame = serde_json::from_str(&json).unwrap(); + assert!(deserialized.source.is_none()); + } + + #[test] + fn scope_serializes_with_variables_reference() { + let scope = Scope { + name: "Local".to_string(), + variables_reference: 1001, + expensive: false, + }; + let json = serde_json::to_string(&scope).unwrap(); + assert!(json.contains(r#""variablesReference":1001"#)); + let deserialized: Scope = serde_json::from_str(&json).unwrap(); + assert_eq!(deserialized.name, "Local"); + assert_eq!(deserialized.variables_reference, 1001); + } + + #[test] + fn variable_with_nested_reference() { + let var = Variable { + name: "myList".to_string(), + value: "List(3)".to_string(), + variables_reference: 2001, + evaluate_name: Some("myList".to_string()), + }; + let json = serde_json::to_string(&var).unwrap(); + let deserialized: Variable = serde_json::from_str(&json).unwrap(); + assert_eq!(deserialized.name, "myList"); + assert_eq!(deserialized.variables_reference, 2001); + assert_eq!(deserialized.evaluate_name.as_deref(), Some("myList")); + } + + #[test] + fn variable_without_evaluate_name() { + let var = Variable { + name: "x".to_string(), + value: "42".to_string(), + variables_reference: 0, + evaluate_name: None, + }; + let json = serde_json::to_string(&var).unwrap(); + let deserialized: Variable = serde_json::from_str(&json).unwrap(); + assert!(deserialized.evaluate_name.is_none()); + } + + #[test] + fn breakpoint_verified_with_line() { + let json = r#"{"id": 5, "verified": true, "line": 10}"#; + let bp: Breakpoint = serde_json::from_str(json).unwrap(); + assert_eq!(bp.id, Some(5)); + assert!(bp.verified); + assert_eq!(bp.line, Some(10)); + } + + #[test] + fn breakpoint_unverified_with_message() { + let json = r#"{"verified": false, "message": "Breakpoint not verified"}"#; + let bp: Breakpoint = serde_json::from_str(json).unwrap(); + assert!(!bp.verified); + assert!(bp.id.is_none()); + assert_eq!(bp.message.as_deref(), Some("Breakpoint not verified")); + } + + #[test] + fn source_breakpoint_with_column() { + let sbp = SourceBreakpoint { + line: 10, + column: Some(5), + }; + let json = serde_json::to_string(&sbp).unwrap(); + assert!(json.contains(r#""line":10"#)); + assert!(json.contains(r#""column":5"#)); + } + + #[test] + fn source_breakpoint_without_column() { + let sbp = SourceBreakpoint { + line: 20, + column: None, + }; + let json = serde_json::to_string(&sbp).unwrap(); + assert!(json.contains(r#""line":20"#)); + } + + #[test] + fn set_breakpoints_arguments_roundtrips() { + let args = SetBreakpointsArguments { + source: Source { + name: Some("main.dart".to_string()), + path: Some("/lib/main.dart".to_string()), + }, + breakpoints: vec![ + SourceBreakpoint { + line: 10, + column: None, + }, + SourceBreakpoint { + line: 20, + column: Some(5), + }, + ], + }; + let json = serde_json::to_string(&args).unwrap(); + let deserialized: SetBreakpointsArguments = serde_json::from_str(&json).unwrap(); + assert_eq!(deserialized.breakpoints.len(), 2); + assert_eq!(deserialized.source.path.as_deref(), Some("/lib/main.dart")); + } + + #[test] + fn initialize_request_arguments_roundtrips() { + let args = InitializeRequestArguments { + client_id: "netherize".to_string(), + client_name: "Netherize Editor".to_string(), + adapter_id: "flutter".to_string(), + lines_start_at_1: true, + columns_start_at_1: true, + path_format: "path".to_string(), + }; + let json = serde_json::to_string(&args).unwrap(); + assert!(json.contains(r#""clientID":"netherize""#)); + assert!(json.contains(r#""adapterID":"flutter""#)); + assert!(json.contains(r#""linesStartAt1":true"#)); + let deserialized: InitializeRequestArguments = serde_json::from_str(&json).unwrap(); + assert_eq!(deserialized.adapter_id, "flutter"); + } + + #[test] + fn stopped_event_body_defaults_thread_id_to_none() { + let json = r#"{"reason": "step"}"#; + let body: StoppedEventBody = serde_json::from_str(json).unwrap(); + assert_eq!(body.reason, "step"); + assert!(body.thread_id.is_none()); + } + + #[test] + fn output_event_body_with_no_category() { + let json = r#"{"output": "raw output"}"#; + let body: OutputEventBody = serde_json::from_str(json).unwrap(); + assert!(body.category.is_none()); + assert_eq!(body.output, "raw output"); + } +} diff --git a/src/lib.rs b/src/lib.rs index adfe1ad0..413d8f3f 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -2,6 +2,7 @@ pub mod app; pub mod async_runtime; pub mod config; pub mod core; +pub mod dap; pub mod editor_core; pub mod lsp; pub mod render; diff --git a/src/render/renderer.rs b/src/render/renderer.rs index 7a43a00d..8bc92fd0 100644 --- a/src/render/renderer.rs +++ b/src/render/renderer.rs @@ -162,6 +162,7 @@ pub(super) struct StatusbarLayoutKey { pub(super) python_version: Option, pub(super) node_version: Option, pub(super) go_version: Option, + pub(super) active_device: Option, } #[derive(Debug, Clone, PartialEq)] @@ -212,6 +213,8 @@ pub struct Renderer { pub relative_numbers: bool, pub(super) last_editor_chrome_instances: Vec, pub(super) editor_breadcrumb_segments: Vec, + pub dap_is_active: bool, + pub dap_is_paused: bool, // ── Explorer sidebar ────────────────────────────────────────────────────── pub(super) sidebar_text_system: TextSystem, diff --git a/src/render/renderer/editor.rs b/src/render/renderer/editor.rs index 3c06c007..e87aad59 100644 --- a/src/render/renderer/editor.rs +++ b/src/render/renderer/editor.rs @@ -212,11 +212,12 @@ pub(super) fn editor_viewport_geometry( let gutter_width = gutter_width_for_editor(gutter_digits, font_size, line_height); let scroll_x = app_state.scroll_column as f32 * (font_size * 0.6).max(1.0); let top_inset = EDITOR_BREADCRUMB_TOP_INSET; - let breadcrumb_height = if renderer.editor_breadcrumb_segments.is_empty() { - 0.0 - } else { - line_height + EDITOR_BREADCRUMB_PAD_Y * 2.0 + EDITOR_BREADCRUMB_GAP_Y - }; + let breadcrumb_height = + if renderer.editor_breadcrumb_segments.is_empty() && !renderer.dap_is_active { + 0.0 + } else { + line_height + EDITOR_BREADCRUMB_PAD_Y * 2.0 + EDITOR_BREADCRUMB_GAP_Y + }; let viewport_text_left = center_bounds[0] + left_inset + gutter_width; let origin_x = viewport_text_left - scroll_x; let viewport_text_width = diff --git a/src/render/renderer/editor/extensions.rs b/src/render/renderer/editor/extensions.rs index b3c566b6..a8d459e5 100644 --- a/src/render/renderer/editor/extensions.rs +++ b/src/render/renderer/editor/extensions.rs @@ -298,7 +298,12 @@ impl Renderer { let shortcut_h = (line_height * 0.82).max(16.0 * s); chrome.push( RegionDrawInstance::new( - [inner_x, shortcuts_y - 2.0 * s, inner_w, shortcut_h + 6.0 * s], + [ + inner_x, + shortcuts_y - 2.0 * s, + inner_w, + shortcut_h + 6.0 * s, + ], overlay, ) .with_radius(4.0 * s), diff --git a/src/render/renderer/editor/overlays.rs b/src/render/renderer/editor/overlays.rs index fc4f49e2..40dded62 100644 --- a/src/render/renderer/editor/overlays.rs +++ b/src/render/renderer/editor/overlays.rs @@ -125,6 +125,68 @@ impl Renderer { x += separator_w; } } + + if self.dap_is_active { + let status_text = if self.dap_is_paused { + "⏸ PAUSED " + } else { + "▶ RUNNING " + }; + let status_color = if self.dap_is_paused { + self.theme.ui.warning.as_f32() + } else { + self.theme.ui.success.as_f32() + }; + + let controls_text = " F5 Cont | F6 Over | F7 Into | F8 Out | ⇧F5 Stop "; + let controls_color = self.theme.ui.fg_ghost.as_f32(); + + // Compute widths + let status_w = estimate_monospace_width(status_text, geometry.font_size); + let controls_w = estimate_monospace_width(controls_text, geometry.font_size); + + // Draw background pill for the active status bar + let pill_w = status_w + controls_w; + let pill_x = header_x + header_w - pill_w - self.editor_padding_x; + let pill_y = header_y + ((header_h - geometry.line_height).max(0.0) * 0.5); + + // Draw a sleek dark border/background for the debug control pill + let pill_bg = blend_rgba(header_bg, self.theme.ui.border_color.as_f32(), 0.3, 1.0); + chrome_quads.push( + RegionDrawInstance::new( + [ + pill_x - 4.0, + pill_y - 2.0, + pill_w + 8.0, + geometry.line_height + 4.0, + ], + pill_bg, + ) + .with_radius(4.0), + ); + + // Draw status + glyphs.extend(layout_panel_text_bold( + status_text, + &mut self.editor_overlay_text_system, + &mut self.atlas, + &self.queue, + pill_x, + pill_y, + status_color, + )); + + // Draw controls + glyphs.extend(layout_panel_text( + controls_text, + &mut self.editor_overlay_text_system, + &mut self.atlas, + &self.queue, + pill_x + status_w, + pill_y, + controls_color, + )); + } } if let Some(diagnostics) = active_diagnostics { diff --git a/src/render/renderer/editor/selections.rs b/src/render/renderer/editor/selections.rs index 5f8d3059..695aa862 100644 --- a/src/render/renderer/editor/selections.rs +++ b/src/render/renderer/editor/selections.rs @@ -683,6 +683,7 @@ impl Renderer { font_size: f32, gutter_digits: usize, gutter_width: f32, + breakpoint_lines: &[usize], ) { let geometry = editor_viewport_geometry(self, app_state, center_bounds); let gutter_inset_left = self.editor_padding_x + 6.0; @@ -946,12 +947,23 @@ impl Renderer { gutter_text_color }; + if breakpoint_lines.contains(&abs_line) { + let dot_size = (run.line_height * 0.52).clamp(8.0, 12.0); + let dot_x = gutter_x + 3.0; + let dot_y = line_top_y + (run.line_height - dot_size) * 0.5; + let dot_color = self.theme.ui.error.as_f32(); + quads.push( + RegionDrawInstance::new([dot_x, dot_y, dot_size, dot_size], dot_color) + .with_radius(dot_size * 0.5), + ); + } + gutter_glyphs.extend(layout_panel_text( &label, &mut self.gutter_text_system, &mut self.atlas, &self.queue, - gutter_x, + gutter_x + 14.0, line_top_y, color, )); diff --git a/src/render/renderer/editor/settings.rs b/src/render/renderer/editor/settings.rs index 63ae3f31..4fa2841e 100644 --- a/src/render/renderer/editor/settings.rs +++ b/src/render/renderer/editor/settings.rs @@ -368,15 +368,16 @@ fn settings_preview_lines(settings: &SettingsState) -> Vec { } } - lines.extend(["".to_string(), "[ai.inline_completion.provider]".to_string()]); + lines.extend([ + "".to_string(), + "[ai.inline_completion.provider]".to_string(), + ]); for item in &settings.items { match item { SettingItem::AiApiUrl { .. } => { lines.push(format!("api_url = {}", item.preview_value())) } - SettingItem::AiModel { .. } => { - lines.push(format!("model = {}", item.preview_value())) - } + SettingItem::AiModel { .. } => lines.push(format!("model = {}", item.preview_value())), SettingItem::AiApiKey { .. } => { lines.push(format!("api_key = {}", item.preview_value())) } @@ -901,12 +902,12 @@ impl Renderer { } } SettingItem::InlineSuggestion { enabled } => { - if *enabled { - "ON" - } else { - "OFF" - } - }, + if *enabled { + "ON" + } else { + "OFF" + } + } _ => "", }; if !badge_label.is_empty() { @@ -1008,12 +1009,7 @@ impl Renderer { preview_start_y = detail_top + detail_h + 12.0 * s; chrome.push( RegionDrawInstance::new( - [ - right_x + 10.0 * s, - detail_top, - right_w - 20.0 * s, - detail_h, - ], + [right_x + 10.0 * s, detail_top, right_w - 20.0 * s, detail_h], with_alpha(panel_bg, 0.55), ) .with_radius(8.0 * s), diff --git a/src/render/renderer/editor/viewport.rs b/src/render/renderer/editor/viewport.rs index 54d346f1..8ea6dfd4 100644 --- a/src/render/renderer/editor/viewport.rs +++ b/src/render/renderer/editor/viewport.rs @@ -314,6 +314,7 @@ impl Renderer { app_state: &AppState, center_bounds: [f32; 4], spans: &[StyledTextSpan], + breakpoint_lines: &[usize], ) { self.caret_blink_visible = true; // Text/scratch/no-tab surfaces share the center viewport with image buffers. @@ -463,6 +464,7 @@ impl Renderer { .len() .max(3), geometry.gutter_width, + breakpoint_lines, ); } @@ -470,7 +472,12 @@ impl Renderer { /// /// Must honor the same mode → shape mapping as `update_editor_content`, otherwise /// h/j/k/l in Normal mode would collapse the block caret back to a thin bar. - pub fn update_editor_caret(&mut self, app_state: &AppState, center_bounds: [f32; 4]) { + pub fn update_editor_caret( + &mut self, + app_state: &AppState, + center_bounds: [f32; 4], + breakpoint_lines: &[usize], + ) { self.caret_blink_visible = true; self.image_pipeline.clear(); self.image_scissor = None; @@ -550,6 +557,7 @@ impl Renderer { .len() .max(3), geometry.gutter_width, + breakpoint_lines, ); } diff --git a/src/render/renderer/lifecycle.rs b/src/render/renderer/lifecycle.rs index 27de0d4c..b78cf19a 100644 --- a/src/render/renderer/lifecycle.rs +++ b/src/render/renderer/lifecycle.rs @@ -214,6 +214,8 @@ impl Renderer { relative_numbers: false, last_editor_chrome_instances: Vec::new(), editor_breadcrumb_segments: Vec::new(), + dap_is_active: false, + dap_is_paused: false, sidebar_text_system, sidebar_text_pipeline, sidebar_glyph_instances: Vec::new(), diff --git a/src/render/renderer/ui/statusbar.rs b/src/render/renderer/ui/statusbar.rs index ac16688f..91923415 100644 --- a/src/render/renderer/ui/statusbar.rs +++ b/src/render/renderer/ui/statusbar.rs @@ -45,6 +45,7 @@ impl Renderer { python_version: Option<&str>, node_version: Option<&str>, go_version: Option<&str>, + active_device: Option<&str>, bounds: [f32; 4], ) -> Vec { if bounds[2] < 1.0 || bounds[3] < 1.0 { @@ -79,6 +80,7 @@ impl Renderer { python_version: python_version.map(str::to_string), node_version: node_version.map(str::to_string), go_version: go_version.map(str::to_string), + active_device: active_device.map(str::to_string), }; if self.last_statusbar_layout_key.as_ref() == Some(&layout_key) { return self.statusbar_chrome_instances.clone(); @@ -181,6 +183,34 @@ impl Renderer { // ══════════════════════════════════════════════════════════════════════════ let mut left_x = pill_x + pill_width + item_gap; + // ── DAP Debug status badge ──────────────────────────────────────────────── + if self.dap_is_active { + let dbg_text = " DEBUG "; + let dbg_w = estimate_monospace_width(dbg_text, font_size); + let badge_bg = if self.dap_is_paused { + warning_fg + } else { + success_fg + }; + let badge_fg = [0.0, 0.0, 0.0, 1.0]; + + chrome.push( + RegionDrawInstance::new([left_x, pill_y, dbg_w, pill_height], badge_bg) + .with_radius(pill_radius), + ); + + glyphs.extend(layout_panel_text_bold( + dbg_text, + &mut self.statusbar_text_system, + &mut self.atlas, + &self.queue, + left_x, + origin_y, + badge_fg, + )); + left_x += dbg_w + item_gap; + } + // ── Git branch ──────────────────────────────────────────────────────────── let branch = git_branch.trim(); if !branch.is_empty() { diff --git a/src/render/renderer/ui/whichkey.rs b/src/render/renderer/ui/whichkey.rs index ec3b73de..d4932199 100644 --- a/src/render/renderer/ui/whichkey.rs +++ b/src/render/renderer/ui/whichkey.rs @@ -80,12 +80,7 @@ impl Renderer { let mut chrome = vec![ RegionDrawInstance::new( - [ - panel_x - 1.0, - panel_y - 1.0, - panel_w + 2.0, - panel_h + 2.0, - ], + [panel_x - 1.0, panel_y - 1.0, panel_w + 2.0, panel_h + 2.0], border_color, ) .with_radius(self.panel_corner_radius + 1.0), @@ -176,7 +171,8 @@ impl Renderer { )); } - self.whichkey_scissor = rect_to_scissor([panel_x - 2.0, panel_y - 2.0, panel_w + 4.0, panel_h + 4.0]); + self.whichkey_scissor = + rect_to_scissor([panel_x - 2.0, panel_y - 2.0, panel_w + 4.0, panel_h + 4.0]); self.whichkey_chrome_instances = chrome; self.whichkey_glyph_instances = glyphs; self.whichkey_text_pipeline.upload_instances( diff --git a/src/terminal/cell_shapes.rs b/src/terminal/cell_shapes.rs index 066376d3..e3c819ca 100644 --- a/src/terminal/cell_shapes.rs +++ b/src/terminal/cell_shapes.rs @@ -190,7 +190,11 @@ fn arm_rects((up, down, left, right): (Arm, Arm, Arm, Arm), w: f32, h: f32) -> V let v_thick = arm_thickness(up, base).max(arm_thickness(down, base)); if v_thick > 0.0 { let x = cx - v_thick / 2.0; - let top = if up != Arm::None { 0.0 } else { cy - v_thick / 2.0 }; + let top = if up != Arm::None { + 0.0 + } else { + cy - v_thick / 2.0 + }; let bottom = if down != Arm::None { h } else { diff --git a/src/terminal/grid.rs b/src/terminal/grid.rs index 20bd9a92..87d31dbd 100644 --- a/src/terminal/grid.rs +++ b/src/terminal/grid.rs @@ -1876,8 +1876,8 @@ mod tests { // 3 visible rows: feeding 5 log lines pushes 2 into scrollback within a // single feed. The incremental pass must still color those 2 rows. let mut grid = TerminalGrid::new(20, 3); - let scrolled = - grid.feed_chunk("E/boom one\r\nE/boom two\r\nE/boom three\r\nE/boom four\r\nE/boom five"); + let scrolled = grid + .feed_chunk("E/boom one\r\nE/boom two\r\nE/boom three\r\nE/boom four\r\nE/boom five"); grid.apply_regex_highlights_incremental(scrolled); let error = grid.highlight_colors.error; diff --git a/src/workbench/debug_state.rs b/src/workbench/debug_state.rs index 214f4ee9..742a8c77 100644 --- a/src/workbench/debug_state.rs +++ b/src/workbench/debug_state.rs @@ -9,6 +9,7 @@ pub struct Breakpoint { pub id: u64, pub location: SourceLocation, pub enabled: bool, + pub path: std::path::PathBuf, } #[derive(Debug, Clone, PartialEq)] @@ -28,6 +29,7 @@ pub struct WatchEntry { pub struct StackFrame { pub function: String, pub location: SourceLocation, + pub path: std::path::PathBuf, } #[derive(Debug, Clone, PartialEq)] @@ -46,93 +48,23 @@ pub struct DebugSharedState { pub call_stack: Vec, pub inline_values: Vec, pub console_messages: Vec, + pub terminated: bool, next_breakpoint_id: u64, } impl Default for DebugSharedState { fn default() -> Self { Self { - paused: true, - execution_location: Some(SourceLocation { line: 6, column: 8 }), - breakpoints: vec![ - Breakpoint { - id: 1, - location: SourceLocation { line: 3, column: 0 }, - enabled: true, - }, - Breakpoint { - id: 2, - location: SourceLocation { - line: 10, - column: 0, - }, - enabled: true, - }, - ], - variables: vec![ - DebugVariable { - name: "counter".to_string(), - value: "42".to_string(), - children: Vec::new(), - }, - DebugVariable { - name: "user".to_string(), - value: "User".to_string(), - children: vec![ - DebugVariable { - name: "id".to_string(), - value: "u_1024".to_string(), - children: Vec::new(), - }, - DebugVariable { - name: "active".to_string(), - value: "true".to_string(), - children: Vec::new(), - }, - ], - }, - ], - watch: vec![ - WatchEntry { - expression: "counter > max".to_string(), - value: "false".to_string(), - }, - WatchEntry { - expression: "items.len()".to_string(), - value: "128".to_string(), - }, - ], - call_stack: vec![ - StackFrame { - function: "process_event".to_string(), - location: SourceLocation { line: 6, column: 8 }, - }, - StackFrame { - function: "main".to_string(), - location: SourceLocation { - line: 42, - column: 2, - }, - }, - ], - inline_values: vec![ - InlineValue { - location: SourceLocation { - line: 6, - column: 14, - }, - text: "counter=42".to_string(), - }, - InlineValue { - location: SourceLocation { - line: 6, - column: 28, - }, - text: "active=true".to_string(), - }, - ], - console_messages: vec!["paused at breakpoint #1".to_string()], - next_breakpoint_id: 3, + paused: false, + execution_location: None, + breakpoints: Vec::new(), + variables: Vec::new(), + watch: Vec::new(), + call_stack: Vec::new(), + inline_values: Vec::new(), + console_messages: Vec::new(), + terminated: false, + next_breakpoint_id: 1, } } } @@ -171,18 +103,18 @@ impl DebugSharedState { true } - pub fn toggle_breakpoint_on_execution_line(&mut self) -> bool { + pub fn toggle_breakpoint_on_execution_line(&mut self, path: &std::path::Path) -> bool { let Some(loc) = self.execution_location else { return false; }; - self.toggle_breakpoint_at_line(loc.line) + self.toggle_breakpoint_at_line(path, loc.line) } - pub fn toggle_breakpoint_at_line(&mut self, line: usize) -> bool { + pub fn toggle_breakpoint_at_line(&mut self, path: &std::path::Path, line: usize) -> bool { if let Some(index) = self .breakpoints .iter() - .position(|bp| bp.location.line == line) + .position(|bp| bp.path == path && bp.location.line == line) { self.breakpoints.remove(index); self.console_messages @@ -194,25 +126,322 @@ impl DebugSharedState { id: self.next_breakpoint_id, location: SourceLocation { line, column: 0 }, enabled: true, + path: path.to_path_buf(), }); self.next_breakpoint_id += 1; self.console_messages .push(format!("breakpoint added at line {}", line + 1)); true } + + pub fn add_watch_expression(&mut self, expression: String) { + if self.watch.iter().any(|w| w.expression == expression) { + return; + } + self.watch.push(WatchEntry { + expression, + value: String::new(), + }); + } + + pub fn remove_watch_expression(&mut self, index: usize) -> bool { + if index < self.watch.len() { + self.watch.remove(index); + return true; + } + false + } + + pub fn update_watch_values(&mut self, values: Vec<(String, String)>) { + for (expr, value) in values { + if let Some(entry) = self.watch.iter_mut().find(|w| w.expression == expr) { + entry.value = value; + } + } + } } #[cfg(test)] mod tests { - use super::DebugSharedState; + use super::*; #[test] fn toggle_breakpoint_adds_and_removes_on_same_line() { let mut state = DebugSharedState::default(); let initial = state.breakpoints.len(); - state.toggle_breakpoint_at_line(25); + state.toggle_breakpoint_at_line(&std::path::PathBuf::new(), 25); assert_eq!(state.breakpoints.len(), initial + 1); - state.toggle_breakpoint_at_line(25); + state.toggle_breakpoint_at_line(&std::path::PathBuf::new(), 25); assert_eq!(state.breakpoints.len(), initial); } + + #[test] + fn default_state_is_empty() { + let state = DebugSharedState::default(); + assert!(!state.paused); + assert!(state.execution_location.is_none()); + assert!(state.breakpoints.is_empty()); + assert!(state.variables.is_empty()); + assert!(state.watch.is_empty()); + assert!(state.call_stack.is_empty()); + assert!(state.inline_values.is_empty()); + assert!(state.console_messages.is_empty()); + assert!(!state.terminated); + } + + #[test] + fn toggle_breakpoint_tracks_path_correctly() { + let mut state = DebugSharedState::default(); + let path_a = std::path::PathBuf::from("/src/main.dart"); + let path_b = std::path::PathBuf::from("/src/lib.rs"); + + state.toggle_breakpoint_at_line(&path_a, 10); + state.toggle_breakpoint_at_line(&path_b, 10); + assert_eq!(state.breakpoints.len(), 2); + + // Same line, different path -> both stay + state.toggle_breakpoint_at_line(&path_a, 10); + assert_eq!(state.breakpoints.len(), 1); + assert_eq!(state.breakpoints[0].path, path_b); + } + + #[test] + fn toggle_breakpoint_assigns_incrementing_ids() { + let mut state = DebugSharedState::default(); + let path = std::path::PathBuf::from("main.dart"); + + state.toggle_breakpoint_at_line(&path, 5); + state.toggle_breakpoint_at_line(&path, 10); + state.toggle_breakpoint_at_line(&path, 15); + + assert_eq!(state.breakpoints[0].id, 1); + assert_eq!(state.breakpoints[1].id, 2); + assert_eq!(state.breakpoints[2].id, 3); + } + + #[test] + fn toggle_breakpoint_on_execution_line() { + let mut state = DebugSharedState::default(); + state.execution_location = Some(SourceLocation { + line: 42, + column: 0, + }); + let path = std::path::PathBuf::from("main.dart"); + + assert!(state.toggle_breakpoint_on_execution_line(&path)); + assert_eq!(state.breakpoints.len(), 1); + assert_eq!(state.breakpoints[0].location.line, 42); + } + + #[test] + fn toggle_breakpoint_on_execution_line_returns_false_when_no_location() { + let mut state = DebugSharedState::default(); + state.execution_location = None; + assert!(!state.toggle_breakpoint_on_execution_line(&std::path::PathBuf::new())); + } + + #[test] + fn add_watch_expression_adds_new() { + let mut state = DebugSharedState::default(); + state.add_watch_expression("x + 1".to_string()); + assert_eq!(state.watch.len(), 1); + assert_eq!(state.watch[0].expression, "x + 1"); + assert!(state.watch[0].value.is_empty()); + } + + #[test] + fn add_watch_expression_prevents_duplicates() { + let mut state = DebugSharedState::default(); + state.add_watch_expression("x".to_string()); + state.add_watch_expression("x".to_string()); + assert_eq!(state.watch.len(), 1); + } + + #[test] + fn add_watch_expression_allows_different_expressions() { + let mut state = DebugSharedState::default(); + state.add_watch_expression("x".to_string()); + state.add_watch_expression("y".to_string()); + state.add_watch_expression("x + y".to_string()); + assert_eq!(state.watch.len(), 3); + } + + #[test] + fn remove_watch_expression_by_index() { + let mut state = DebugSharedState::default(); + state.add_watch_expression("a".to_string()); + state.add_watch_expression("b".to_string()); + state.add_watch_expression("c".to_string()); + + assert!(state.remove_watch_expression(1)); + assert_eq!(state.watch.len(), 2); + assert_eq!(state.watch[0].expression, "a"); + assert_eq!(state.watch[1].expression, "c"); + } + + #[test] + fn remove_watch_expression_out_of_bounds_returns_false() { + let mut state = DebugSharedState::default(); + state.add_watch_expression("x".to_string()); + assert!(!state.remove_watch_expression(5)); + assert_eq!(state.watch.len(), 1); + } + + #[test] + fn remove_watch_expression_empty_list_returns_false() { + let mut state = DebugSharedState::default(); + assert!(!state.remove_watch_expression(0)); + } + + #[test] + fn update_watch_values_matches_by_expression() { + let mut state = DebugSharedState::default(); + state.add_watch_expression("x".to_string()); + state.add_watch_expression("y".to_string()); + + state.update_watch_values(vec![ + ("x".to_string(), "42".to_string()), + ("y".to_string(), "hello".to_string()), + ]); + + assert_eq!(state.watch[0].value, "42"); + assert_eq!(state.watch[1].value, "hello"); + } + + #[test] + fn update_watch_values_ignores_unknown_expressions() { + let mut state = DebugSharedState::default(); + state.add_watch_expression("x".to_string()); + + state.update_watch_values(vec![("unknown".to_string(), "999".to_string())]); + + assert!(state.watch[0].value.is_empty()); + } + + #[test] + fn update_watch_values_overwrites_existing() { + let mut state = DebugSharedState::default(); + state.add_watch_expression("x".to_string()); + + state.update_watch_values(vec![("x".to_string(), "first".to_string())]); + assert_eq!(state.watch[0].value, "first"); + + state.update_watch_values(vec![("x".to_string(), "second".to_string())]); + assert_eq!(state.watch[0].value, "second"); + } + + #[test] + fn move_execution_down_increments_line() { + let mut state = DebugSharedState::default(); + state.execution_location = Some(SourceLocation { line: 0, column: 0 }); + assert!(state.move_execution_down(10)); + assert_eq!(state.execution_location.unwrap().line, 1); + } + + #[test] + fn move_execution_down_stops_at_max() { + let mut state = DebugSharedState::default(); + state.execution_location = Some(SourceLocation { line: 9, column: 0 }); + assert!(!state.move_execution_down(10)); + assert_eq!(state.execution_location.unwrap().line, 9); + } + + #[test] + fn move_execution_up_decrements_line() { + let mut state = DebugSharedState::default(); + state.execution_location = Some(SourceLocation { line: 5, column: 0 }); + assert!(state.move_execution_up()); + assert_eq!(state.execution_location.unwrap().line, 4); + } + + #[test] + fn move_execution_up_stops_at_zero() { + let mut state = DebugSharedState::default(); + state.execution_location = Some(SourceLocation { line: 0, column: 0 }); + assert!(!state.move_execution_up()); + } + + #[test] + fn move_execution_returns_false_when_no_location() { + let mut state = DebugSharedState::default(); + assert!(!state.move_execution_down(10)); + assert!(!state.move_execution_up()); + } + + #[test] + fn toggle_paused_flips_state() { + let mut state = DebugSharedState::default(); + assert!(!state.paused); + state.toggle_paused(); + assert!(state.paused); + state.toggle_paused(); + assert!(!state.paused); + } + + #[test] + fn toggle_paused_pushes_console_messages() { + let mut state = DebugSharedState::default(); + state.toggle_paused(); + assert!(state.console_messages.iter().any(|m| m.contains("paused"))); + state.toggle_paused(); + assert!(state.console_messages.iter().any(|m| m.contains("resumed"))); + } + + #[test] + fn breakpoint_defaults_are_correct() { + let bp = Breakpoint { + id: 42, + location: SourceLocation { + line: 10, + column: 5, + }, + enabled: true, + path: std::path::PathBuf::from("main.dart"), + }; + assert_eq!(bp.id, 42); + assert!(bp.enabled); + assert_eq!(bp.location.line, 10); + } + + #[test] + fn stack_frame_stores_location_and_path() { + let frame = StackFrame { + function: "main".to_string(), + location: SourceLocation { + line: 42, + column: 2, + }, + path: std::path::PathBuf::from("/lib/main.dart"), + }; + assert_eq!(frame.function, "main"); + assert_eq!(frame.location.line, 42); + assert_eq!(frame.path.to_str(), Some("/lib/main.dart")); + } + + #[test] + fn debug_variable_supports_nested_children() { + let var = DebugVariable { + name: "list".to_string(), + value: "List(3)".to_string(), + children: vec![ + DebugVariable { + name: "[0]".to_string(), + value: "1".to_string(), + children: Vec::new(), + }, + DebugVariable { + name: "[1]".to_string(), + value: "2".to_string(), + children: vec![DebugVariable { + name: "nested".to_string(), + value: "deep".to_string(), + children: Vec::new(), + }], + }, + ], + }; + assert_eq!(var.children.len(), 2); + assert_eq!(var.children[1].children.len(), 1); + assert_eq!(var.children[1].children[0].name, "nested"); + } } diff --git a/src/workbench/inspector_panel.rs b/src/workbench/inspector_panel.rs index a963a06e..ac24d41d 100644 --- a/src/workbench/inspector_panel.rs +++ b/src/workbench/inspector_panel.rs @@ -254,19 +254,43 @@ impl InspectorPanelState { } let header_gap = 6.0; + let collapsed_h = 26.0_f32; let total_gap = header_gap * (self.sections.len().saturating_sub(1) as f32); - let section_h = - ((sidebar_bounds.height - total_gap) / self.sections.len() as f32).max(20.0); + + let collapsed_count = self.sections.iter().filter(|s| !s.expanded).count(); + let expanded_count = self.sections.len() - collapsed_count; + + let collapsed_total_h = collapsed_count as f32 * collapsed_h; + let remaining_h = (sidebar_bounds.height - total_gap - collapsed_total_h).max(0.0); + + let expanded_h = if expanded_count > 0 { + (remaining_h / expanded_count as f32).max(20.0) + } else { + 0.0 + }; let mut surfaces = Vec::with_capacity(self.sections.len()); - for (index, section) in self.sections.iter().enumerate() { - let y = sidebar_bounds.y + index as f32 * (section_h + header_gap); + let mut current_y = sidebar_bounds.y; + + for section in &self.sections { + let section_h = if section.expanded { + expanded_h + } else { + collapsed_h + }; surfaces.push(InspectorSectionSurface { kind: section.kind, - bounds: RegionBounds::new(sidebar_bounds.x, y, sidebar_bounds.width, section_h), + bounds: RegionBounds::new( + sidebar_bounds.x, + current_y, + sidebar_bounds.width, + section_h, + ), selected: selected_section.is_some_and(|kind| kind == section.kind), }); + current_y += section_h + header_gap; } + surfaces } @@ -334,9 +358,11 @@ fn node_mut_from_slice<'a>( #[cfg(test)] mod tests { - use crate::workbench::debug_state::DebugSharedState; + use crate::workbench::debug_state::{ + DebugSharedState, DebugVariable, SourceLocation, StackFrame, WatchEntry, + }; - use super::InspectorPanelState; + use super::*; #[test] fn inspector_rows_support_navigation_and_toggle() { @@ -348,4 +374,315 @@ mod tests { assert!(state.move_selection_next()); assert!(state.selected_row_label().is_some()); } + + #[test] + fn sync_populates_variables_section() { + let mut debug = DebugSharedState::default(); + debug.variables = vec![ + DebugVariable { + name: "x".to_string(), + value: "42".to_string(), + children: Vec::new(), + }, + DebugVariable { + name: "y".to_string(), + value: "hello".to_string(), + children: vec![DebugVariable { + name: "child".to_string(), + value: "nested".to_string(), + children: Vec::new(), + }], + }, + ]; + + let mut state = InspectorPanelState::default(); + state.sync_from_debug_state(&debug); + + let var_section = &state.sections[0]; + assert_eq!(var_section.nodes.len(), 2); + assert_eq!(var_section.nodes[0].label, "x = 42"); + assert!(var_section.nodes[0].children.is_empty()); + assert_eq!(var_section.nodes[1].label, "y = hello"); + assert_eq!(var_section.nodes[1].children.len(), 1); + assert!(var_section.nodes[1].expanded); // branch nodes default to expanded + } + + #[test] + fn sync_populates_watch_section() { + let mut debug = DebugSharedState::default(); + debug.watch = vec![ + WatchEntry { + expression: "x + 1".to_string(), + value: "43".to_string(), + }, + WatchEntry { + expression: "items.len()".to_string(), + value: "5".to_string(), + }, + ]; + + let mut state = InspectorPanelState::default(); + state.sync_from_debug_state(&debug); + + let watch_section = &state.sections[1]; + assert_eq!(watch_section.nodes.len(), 2); + assert_eq!(watch_section.nodes[0].label, "x + 1 = 43"); + assert_eq!(watch_section.nodes[1].label, "items.len() = 5"); + } + + #[test] + fn sync_populates_call_stack_section() { + let mut debug = DebugSharedState::default(); + debug.call_stack = vec![ + StackFrame { + function: "main".to_string(), + location: SourceLocation { line: 0, column: 0 }, + path: std::path::PathBuf::from("main.dart"), + }, + StackFrame { + function: "build".to_string(), + location: SourceLocation { + line: 41, + column: 8, + }, + path: std::path::PathBuf::from("widget.dart"), + }, + ]; + + let mut state = InspectorPanelState::default(); + state.sync_from_debug_state(&debug); + + let stack_section = &state.sections[2]; + assert_eq!(stack_section.nodes.len(), 2); + assert!(stack_section.nodes[0].label.contains("main")); + assert!(stack_section.nodes[0].label.contains("1:1")); // 0-indexed + 1 + assert!(stack_section.nodes[1].label.contains("build")); + assert!(stack_section.nodes[1].label.contains("42:9")); // line+1, col+1 + } + + #[test] + fn sync_populates_breakpoints_section() { + let mut debug = DebugSharedState::default(); + debug.toggle_breakpoint_at_line(&std::path::PathBuf::from("main.dart"), 5); + debug.toggle_breakpoint_at_line(&std::path::PathBuf::from("main.dart"), 10); + + let mut state = InspectorPanelState::default(); + state.sync_from_debug_state(&debug); + + let bp_section = &state.sections[3]; + assert_eq!(bp_section.nodes.len(), 2); + assert!(bp_section.nodes[0].label.contains("line 6")); // 0-indexed + 1 + assert!(bp_section.nodes[1].label.contains("line 11")); + } + + #[test] + fn sync_clamps_selected_row_when_shrinking() { + let mut debug = DebugSharedState::default(); + debug.variables = vec![ + DebugVariable { + name: "a".to_string(), + value: "1".to_string(), + children: Vec::new(), + }, + DebugVariable { + name: "b".to_string(), + value: "2".to_string(), + children: Vec::new(), + }, + ]; + + let mut state = InspectorPanelState::default(); + state.sync_from_debug_state(&debug); + state.selected_row = 100; // Way out of bounds + + // Sync again with empty data + state.sync_from_debug_state(&DebugSharedState::default()); + let rows = state.visible_rows(); + assert!(state.selected_row < rows.len()); + } + + #[test] + fn visible_rows_include_section_headers_and_items() { + let mut debug = DebugSharedState::default(); + debug.variables = vec![DebugVariable { + name: "x".to_string(), + value: "1".to_string(), + children: Vec::new(), + }]; + + let mut state = InspectorPanelState::default(); + state.sync_from_debug_state(&debug); + + let rows = state.visible_rows(); + // At minimum: 4 section headers + 1 variable + assert!(rows.len() >= 5); + + // First row should be Variables header + assert_eq!(rows[0].label, "Variables"); + assert!(rows[0].node_path.is_none()); // Section header + assert_eq!(rows[0].depth, 0); + + // Second row should be the variable + assert_eq!(rows[1].label, "x = 1"); + assert!(rows[1].node_path.is_some()); + assert_eq!(rows[1].depth, 1); + } + + #[test] + fn collapsed_section_hides_children() { + let mut debug = DebugSharedState::default(); + debug.variables = vec![DebugVariable { + name: "x".to_string(), + value: "1".to_string(), + children: Vec::new(), + }]; + + let mut state = InspectorPanelState::default(); + state.sync_from_debug_state(&debug); + + let rows_expanded = state.visible_rows(); + let expanded_count = rows_expanded.len(); + + // Collapse the Variables section + state.sections[0].expanded = false; + let rows_collapsed = state.visible_rows(); + assert!(rows_collapsed.len() < expanded_count); + } + + #[test] + fn toggle_expand_on_section_header() { + let mut state = InspectorPanelState::default(); + state.sync_from_debug_state(&DebugSharedState::default()); + + let was_expanded = state.sections[0].expanded; + state.selected_row = 0; // First row is Variables header + assert!(state.toggle_selected_expand()); + assert_eq!(state.sections[0].expanded, !was_expanded); + } + + #[test] + fn toggle_expand_on_node_with_children() { + let mut debug = DebugSharedState::default(); + debug.variables = vec![DebugVariable { + name: "list".to_string(), + value: "List(2)".to_string(), + children: vec![ + DebugVariable { + name: "[0]".to_string(), + value: "a".to_string(), + children: Vec::new(), + }, + DebugVariable { + name: "[1]".to_string(), + value: "b".to_string(), + children: Vec::new(), + }, + ], + }]; + + let mut state = InspectorPanelState::default(); + state.sync_from_debug_state(&debug); + + // Find the row for "list = List(2)" + let rows = state.visible_rows(); + let list_row_idx = rows.iter().position(|r| r.label.contains("list")).unwrap(); + state.selected_row = list_row_idx; + + // Collapse it + assert!(state.toggle_selected_expand()); + let rows_after = state.visible_rows(); + // Should have fewer rows now (the children are hidden) + assert!(rows_after.len() < rows.len()); + } + + #[test] + fn toggle_expand_on_leaf_node_returns_false() { + let mut debug = DebugSharedState::default(); + debug.variables = vec![DebugVariable { + name: "x".to_string(), + value: "42".to_string(), + children: Vec::new(), + }]; + + let mut state = InspectorPanelState::default(); + state.sync_from_debug_state(&debug); + + // Find the leaf node row + let rows = state.visible_rows(); + let leaf_idx = rows + .iter() + .position(|r| r.label.contains("x = 42")) + .unwrap(); + state.selected_row = leaf_idx; + + // Toggle on leaf should return false (no children) + assert!(!state.toggle_selected_expand()); + } + + #[test] + fn section_surfaces_compute_dynamic_height() { + let mut state = InspectorPanelState::default(); + state.sync_from_debug_state(&DebugSharedState::default()); + + let bounds = RegionBounds::new(0.0, 0.0, 300.0, 800.0); + let surfaces = state.section_surfaces(bounds, None); + + assert_eq!(surfaces.len(), 4); + // All sections are expanded by default, so each should get ~1/4 of the height + for surface in &surfaces { + assert!(surface.bounds.height > 0.0); + } + } + + #[test] + fn section_surfaces_collapsed_gets_fixed_height() { + let mut state = InspectorPanelState::default(); + state.sections[0].expanded = false; // Collapse Variables + + let bounds = RegionBounds::new(0.0, 0.0, 300.0, 800.0); + let surfaces = state.section_surfaces(bounds, None); + + // Collapsed section should have height ~26.0 + assert!((surfaces[0].bounds.height - 26.0).abs() < 1.0); + } + + #[test] + fn move_selection_wraps_correctly() { + let mut state = InspectorPanelState::default(); + state.sync_from_debug_state(&DebugSharedState::default()); + + let rows = state.visible_rows(); + let total = rows.len(); + + // Move to end + for _ in 0..total + 5 { + state.move_selection_next(); + } + assert_eq!(state.selected_row, total - 1); + + // Move back to start + for _ in 0..total + 5 { + state.move_selection_prev(); + } + assert_eq!(state.selected_row, 0); + } + + #[test] + fn selected_row_label_returns_current_row_text() { + let mut debug = DebugSharedState::default(); + debug.variables = vec![DebugVariable { + name: "test_var".to_string(), + value: "999".to_string(), + children: Vec::new(), + }]; + + let mut state = InspectorPanelState::default(); + state.sync_from_debug_state(&debug); + + // Navigate to the variable row (skip Variables header) + state.move_selection_next(); + let label = state.selected_row_label(); + assert!(label.is_some()); + assert!(label.unwrap().contains("test_var")); + } } diff --git a/src/workbench/overlay_manager.rs b/src/workbench/overlay_manager.rs index e1f9fff9..ebef3b19 100644 --- a/src/workbench/overlay_manager.rs +++ b/src/workbench/overlay_manager.rs @@ -197,7 +197,8 @@ fn clamp_to_bounds(rect: RegionBounds, bounds: RegionBounds) -> RegionBounds { #[cfg(test)] mod tests { use crate::workbench::{ - debug_state::DebugSharedState, region_model::RegionBounds, + debug_state::{DebugSharedState, InlineValue, SourceLocation}, + region_model::RegionBounds, text_coordinate_map::TextCoordinateMapper, }; @@ -208,7 +209,17 @@ mod tests { let mut manager = OverlayManager::default(); manager.command_palette_open = true; manager.command_palette_query = "src/main".to_string(); - let debug_state = DebugSharedState::default(); + let mut debug_state = DebugSharedState::default(); + debug_state.execution_location = Some(SourceLocation { line: 2, column: 4 }); + debug_state.paused = true; + debug_state.inline_values = vec![InlineValue { + location: SourceLocation { + line: 2, + column: 10, + }, + text: "x = 42".to_string(), + }]; + debug_state.toggle_breakpoint_at_line(&std::path::PathBuf::from("main.dart"), 0); let mapper = TextCoordinateMapper::from_text( "a\nb\nc\nd\ne\nf\ng\nh", RegionBounds::new(40.0, 120.0, 700.0, 420.0), diff --git a/src/workbench/panel_state.rs b/src/workbench/panel_state.rs index efdb4814..6431ba0c 100644 --- a/src/workbench/panel_state.rs +++ b/src/workbench/panel_state.rs @@ -16,7 +16,7 @@ impl PanelTabId { match self { Self::Explorer => "Explorer", Self::Search => "Search", - Self::Inspector => "Inspector", + Self::Inspector => "DAP", Self::Outline => "Outline", Self::Terminal => "Terminal", Self::DebugConsole => "Debug Console", @@ -188,26 +188,26 @@ pub struct WorkbenchPanelState { impl Default for WorkbenchPanelState { fn default() -> Self { Self { - left: PanelState::new(true, 240.0, vec![PanelTabId::Explorer, PanelTabId::Search]), + left: PanelState::new( + true, + 240.0, + vec![PanelTabId::Explorer, PanelTabId::Inspector], + ), right: PanelState::new( false, 650.0, vec![ PanelTabId::Terminal, + PanelTabId::Search, PanelTabId::AiChat, PanelTabId::MarkdownPreview, - PanelTabId::Inspector, PanelTabId::Outline, ], ), bottom: PanelState::new( false, 420.0, - vec![ - PanelTabId::Terminal, - PanelTabId::DebugConsole, - PanelTabId::Problems, - ], + vec![PanelTabId::Terminal, PanelTabId::DebugConsole], ), overlay_visible: false, ai_chat: AiChatState::default(), diff --git a/test_fvm_detection.sh b/test_fvm_detection.sh new file mode 100755 index 00000000..6267ab3b --- /dev/null +++ b/test_fvm_detection.sh @@ -0,0 +1,44 @@ +#!/bin/bash +# Test FVM detection logic + +echo "=== Testing FVM Detection ===" +echo "" + +# Test workspace with local FVM +TEST_WORKSPACE="/Users/qc-bright/Project/mine_wallet" +echo "Test workspace: $TEST_WORKSPACE" +echo "" + +# Check if .fvm exists +if [ -d "$TEST_WORKSPACE/.fvm" ]; then + echo "✓ .fvm directory exists" +else + echo "✗ .fvm directory not found" + exit 1 +fi + +# Check if pubspec.yaml exists +if [ -f "$TEST_WORKSPACE/pubspec.yaml" ]; then + echo "✓ pubspec.yaml exists" +else + echo "✗ pubspec.yaml not found" + exit 1 +fi + +# Check if local FVM dart binary exists +LOCAL_DART="$TEST_WORKSPACE/.fvm/flutter_sdk/bin/cache/dart-sdk/bin/dart" +if [ -f "$LOCAL_DART" ]; then + echo "✓ Local FVM dart binary exists: $LOCAL_DART" + echo " Version: $($LOCAL_DART --version 2>&1 | head -1)" +else + echo "✗ Local FVM dart binary not found" + exit 1 +fi + +echo "" +echo "=== FVM Detection Test Passed ===" +echo "" +echo "Expected behavior:" +echo " When opening a .dart file in $TEST_WORKSPACE," +echo " the LSP server should use: $LOCAL_DART" +echo " instead of system dart" diff --git a/tests/lsp_fvm_detection.rs b/tests/lsp_fvm_detection.rs index 5997cc25..ae6a35fc 100644 --- a/tests/lsp_fvm_detection.rs +++ b/tests/lsp_fvm_detection.rs @@ -41,4 +41,41 @@ mod lsp_fvm_detection_tests { "Non-Dart workspace should not have pubspec.yaml" ); } + + #[tokio::test] + async fn test_real_dap_session_with_flutter() { + let flutter_bin = "/Users/qc-bright/Project/mine_wallet/.fvm/flutter_sdk/bin/flutter"; + if !std::path::Path::new(flutter_bin).exists() { + eprintln!("FVM flutter not found, skipping real DAP integration test"); + return; + } + + let (event_tx, _event_rx) = tokio::sync::mpsc::unbounded_channel(); + let client = netherize_editor::dap::DapClient::launch( + flutter_bin, + &["debug_adapter".to_string()], + Some(PathBuf::from("/Users/qc-bright/Project/mine_wallet")), + event_tx, + ) + .expect("launch flutter debug adapter"); + + // Send initialize request + let init_args = serde_json::json!({ + "adapterID": "flutter", + "clientID": "netherize_editor_test", + "linesStartAt1": true, + "columnsStartAt1": true, + "pathFormat": "path" + }); + + let init_resp = client + .send_request("initialize", Some(init_args)) + .await + .expect("send initialize request"); + assert!(init_resp.success, "initialize request should be successful"); + assert!( + init_resp.body.is_some(), + "initialize response should have capabilities" + ); + } }