From d726575100ddb66e52766a6e444d35280377df80 Mon Sep 17 00:00:00 2001 From: Colin Neilens Date: Tue, 22 Sep 2026 09:59:08 -0700 Subject: [PATCH 1/2] Add Windows prompt-attachments parity slice Windows equivalent of macOS's ProjectFeature+Attachments.swift / NodeDraftAttachments.swift: attach supported files/images from a native multi-select file picker into the New Node dialog, ingest them with size/type validation, display name/type/size with removal before submit, persist staged assets to a bounded per-draft attachments directory, and carry attachment references in the typed node draft and daemon wire payload in the same PromptAttachment shape Swift's NodeDraft decodes. - DraftAttachments.zig (new): extension/size validation, project-path slugging, attachments-directory resolution, file ingestion, directory cleanup, and [image #N] token generation/removal-with-renumbering, ported from the macOS reference. 8 unit tests. - FilePicker.c/.h (new): IFileOpenDialog-based multi-select file picker with an extension filter matching the supported types, mirroring the existing FolderPicker.c. - Forms.zig: NodeDraft gains node_id/attachment_paths/attachment_ids/ attachment_count; validateNode bounds attachment_count; new public generateDraftId lets a draft's id be chosen before its dialog opens (needed so attachments can be filed under the id the node will eventually carry), hardened against same-tick collisions with a process-lifetime counter. - Wire.zig: commandGraphCreateNodeFull now encodes "attachments" as [{"id":...,"path":...}], always an array, matching Swift PromptAttachment's Codable shape exactly. - DaemonClient.zig: sendCreateNodeDraft prefers draft.node_id when the dialog staged at least one attachment, instead of always generating a fresh id at send time. - App.zig: createNode pre-generates the draft id before opening the dialog so attachments can be filed under it while the dialog is open. - NativeForms.zig: adds an Attachments section to the node dialog as an independent custom-drawn section (list, Attach/Remove buttons, help text) appended after the generic field loop, rather than renumbering the existing worktree/subgraph/createdBy pass-through field indices - this keeps every existing field index, label, and values[] slot untouched. Handles attach/remove, [image #N] token insertion into the active brief field, lazy per-draft directory creation, listbox refresh, and cleanup of staged files on cancel or post-validation failure. - windows-tests/GraphCommandInteropTests.swift + new fixture: decodes a node draft with an attachment through the real Swift NodeDraft/ PromptAttachment Codable types and PromptAttachments.resolving. Diffs to Wire.zig/NativeForms.zig/DaemonClient.zig were kept to their minimal logical changes (not a whole-file zig fmt reformat), since these files were not already zig-fmt-clean under the pinned 0.15.2 formatter and another branch may also be editing NativeForms.zig. Testing: - zig test src\Forms.zig: 59/59 passed (Forms + GraphModel + Wire, including new attachments wire-encoding and generateDraftId tests). - zig test src\NativeForms.zig -lc (with a stub winghostty header to get past cImport, since this environment has no pinned Winghostty provider): 92/92 passed, including new tests for buildNodeDraft's attachment/node_id carry-through and attachment section visibility. Known blockers / remaining scope: - Full exe build and WindowsShell/UIA live-automation evidence require bootstrapping a pinned Winghostty provider worktree (Tools/windows/bootstrap.ps1), not available in this environment. - Clipboard paste and drag-drop attachment support were not implemented; left as documented remaining scope rather than partially faked. - Swift interop test run (windows-tests/GraphCommandInteropTests.swift) could not be executed locally: SwiftPM dependency resolution hits an environment-enforced git safe.bareRepository=explicit policy that blocks fetching tags from the cached bare dependency repos. The new fixture/test were written to the same pattern as the existing passing cases and should decode identically in CI. Signed-off-by: Colin Neilens Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- graphcode-windows/build.zig | 4 + ...ift-node-draft-with-attachments-valid.json | 1 + graphcode-windows/src/App.zig | 18 +- graphcode-windows/src/DaemonClient.zig | 22 +- graphcode-windows/src/DraftAttachments.zig | 299 ++++++++++++ graphcode-windows/src/FilePicker.c | 72 +++ graphcode-windows/src/FilePicker.h | 9 + graphcode-windows/src/Forms.zig | 59 +++ graphcode-windows/src/NativeForms.zig | 446 +++++++++++++++++- graphcode-windows/src/Wire.zig | 42 ++ windows-tests/GraphCommandInteropTests.swift | 16 + 11 files changed, 953 insertions(+), 35 deletions(-) create mode 100644 graphcode-windows/fixtures/swift-node-draft-with-attachments-valid.json create mode 100644 graphcode-windows/src/DraftAttachments.zig create mode 100644 graphcode-windows/src/FilePicker.c create mode 100644 graphcode-windows/src/FilePicker.h diff --git a/graphcode-windows/build.zig b/graphcode-windows/build.zig index d1a73e77..bea4350f 100644 --- a/graphcode-windows/build.zig +++ b/graphcode-windows/build.zig @@ -51,6 +51,10 @@ pub fn build(b: *std.Build) !void { .file = b.path("src/FolderPicker.c"), .flags = &.{ "-DUNICODE", "-D_UNICODE" }, }); + exe.addCSourceFile(.{ + .file = b.path("src/FilePicker.c"), + .flags = &.{ "-DUNICODE", "-D_UNICODE" }, + }); exe.addCSourceFile(.{ .file = b.path("src/AccessibilityProvider.cpp"), .flags = &.{ "-Wno-unused-command-line-argument" }, diff --git a/graphcode-windows/fixtures/swift-node-draft-with-attachments-valid.json b/graphcode-windows/fixtures/swift-node-draft-with-attachments-valid.json new file mode 100644 index 00000000..d49b1735 --- /dev/null +++ b/graphcode-windows/fixtures/swift-node-draft-with-attachments-valid.json @@ -0,0 +1 @@ +{"id":"22222222-2222-4222-8222-222222222222","title":"Loop","loopType":"turnBased","checkDescription":"check","triggerPrompt":null,"firstInstruction":"look at [image #1]","pausesBeforeWritesOnly":false,"goal":null,"backend":null,"modelTier":null,"worktree":null,"subGraph":null,"createdBy":null,"attachments":[{"id":"aaaaaaaa-1111-4111-8111-111111111111","path":"C:\\Users\\me\\.graphcode\\memory\\my-project\\22222222-2222-4222-8222-222222222222\\attachments\\attachment-1.png"}]} diff --git a/graphcode-windows/src/App.zig b/graphcode-windows/src/App.zig index 348d77da..c3979001 100644 --- a/graphcode-windows/src/App.zig +++ b/graphcode-windows/src/App.zig @@ -1136,6 +1136,12 @@ pub const App = struct { const path = self.allocator.dupe(u8, current_path) catch return; defer self.allocator.free(path); const settings = self.product_settings orelse return; + // Generated before the dialog opens (rather than at send time, as every other + // draft field is) so a file picked mid-dialog can be copied straight into the + // attachments directory this node will end up owning, instead of a temporary + // location that would need a second copy once the real id is known. + var draft_id_buffer: [36]u8 = undefined; + Forms.generateDraftId(&draft_id_buffer); const initial = Forms.NodeDraft{ .title = "", .backend = settings.default_backend, @@ -1151,15 +1157,11 @@ pub const App = struct { }; defer templates.deinit(); if (templates.templates.items.len == 0) { - var draft = NativeForms.node(self.window.hwnd, self.allocator, initial) catch { + var draft = NativeForms.node(self.window.hwnd, self.allocator, path, &draft_id_buffer, initial) catch { self.setStatus("Unable to open node form"); return; } orelse return; defer draft.deinit(self.allocator); - Forms.validateNode(draft) catch { - self.setStatus("Invalid node form"); - return; - }; self.client.sendCreateNodeDraft(path, draft); return; } @@ -1185,7 +1187,7 @@ pub const App = struct { var owns_current = false; defer if (owns_current) current.deinit(self.allocator); while (true) { - const result = NativeForms.nodeWithTemplates(self.window.hwnd, self.allocator, current, true) catch { + const result = NativeForms.nodeWithTemplates(self.window.hwnd, self.allocator, path, &draft_id_buffer, current, true) catch { self.setStatus("Unable to open node form"); return; }; @@ -1194,10 +1196,6 @@ pub const App = struct { .draft => |draft| { var submitted = draft; defer submitted.deinit(self.allocator); - Forms.validateNode(submitted) catch { - self.setStatus("Invalid node form"); - return; - }; self.client.sendCreateNodeDraft(path, submitted); return; }, diff --git a/graphcode-windows/src/DaemonClient.zig b/graphcode-windows/src/DaemonClient.zig index f33395f1..494a88b9 100644 --- a/graphcode-windows/src/DaemonClient.zig +++ b/graphcode-windows/src/DaemonClient.zig @@ -363,13 +363,21 @@ pub const DaemonClient = struct { project_path: []const u8, draft: Forms.NodeDraft, ) void { - var node_id: [36]u8 = undefined; - self.mutex.lock(); - const sequence = self.next_draft; - self.next_draft +%= 1; - self.mutex.unlock(); - makeRequestID(&node_id, sequence); - const command = Wire.commandGraphCreateNodeFull(self.allocator, project_path, &node_id, draft) catch { + var generated_id: [36]u8 = undefined; + // A draft that ingested an attachment already chose its id before the dialog + // opened (`Forms.generateDraftId`, threaded through `NativeForms.node`) — the + // attachment bytes are already filed under that id on disk, so the node this + // creates has to carry the same one. Everything else keeps the historical + // generate-at-send-time id. + const node_id: []const u8 = if (draft.node_id.len != 0) draft.node_id else blk: { + self.mutex.lock(); + const sequence = self.next_draft; + self.next_draft +%= 1; + self.mutex.unlock(); + makeRequestID(&generated_id, sequence); + break :blk &generated_id; + }; + const command = Wire.commandGraphCreateNodeFull(self.allocator, project_path, node_id, draft) catch { self.publishState(self.connectionState(), "create node command encoding failed"); return; }; diff --git a/graphcode-windows/src/DraftAttachments.zig b/graphcode-windows/src/DraftAttachments.zig new file mode 100644 index 00000000..d3ced5d5 --- /dev/null +++ b/graphcode-windows/src/DraftAttachments.zig @@ -0,0 +1,299 @@ +const std = @import("std"); + +/// Windows' half of the New Node dialog's file attachments — what a native file picker +/// hands the form, and where those bytes land before a node exists to own them. +/// +/// The macOS reference is `graphcode/Sources/Features/Project/ProjectFeature+Attachments.swift` +/// and `NodeDraftAttachments.swift`: an image is written to disk under the *draft's own +/// id* the moment it is picked, and a `[image #N]` placeholder stands in for it in the +/// brief field the human is typing into. The daemon (`GraphcodeKit`'s shared +/// `PromptAttachments.resolving`) swaps the placeholder for the real path when the +/// session actually opens — this module never needs to know how that prompt gets built, +/// only where the bytes go and what a token looks like. +/// +/// Everything here is pure logic plus filesystem calls: no Win32, so it is exercised the +/// same way `Forms.zig`'s validators are, with plain `zig test`. +/// Bigger than this is refused, matching macOS `DraftImageImport.maximumBytes` — an +/// agent reads a screenshot or a short note, not a poster, and every byte is copied +/// synchronously while the dialog is open. +pub const max_bytes: usize = 10 * 1024 * 1024; + +/// How many files one draft may carry. Unlike macOS, the Windows dialog lays out a +/// fixed-size list control rather than a scrolling SwiftUI stack, so the count needs an +/// upper bound; eight is more than any one prompt should reasonably reference. +pub const max_attachments: usize = 8; + +/// Extensions a backend can actually open once the path lands in its prompt. Images +/// mirror macOS's `DraftImageImport.imageExtensions` exactly; the plain-text kinds are +/// the Windows-only broadening the task asked for ("attach supported files/images"), +/// kept to formats every backend's own tools already read without a special viewer. +const supported_extensions = [_][]const u8{ + "png", "jpg", "jpeg", "gif", "heic", "webp", "tiff", "tif", "bmp", + "txt", "md", "log", "json", "csv", "pdf", +}; + +pub const IngestError = error{ + UnsupportedFileType, + FileTooLarge, + EmptyFile, + SourceUnreadable, + TooManyAttachments, + DestinationUnwritable, +} || std.mem.Allocator.Error; + +/// The file extension (no dot, original case), or "" for a dotfile or extension-less +/// name — both of which fail `isSupportedExtension` rather than being special-cased. +pub fn extensionOf(path: []const u8) []const u8 { + const base = std.fs.path.basename(path); + const dot = std.mem.lastIndexOfScalar(u8, base, '.') orelse return ""; + if (dot == 0) return ""; + return base[dot + 1 ..]; +} + +/// Case-insensitive membership in `supported_extensions`. `.len` is bounded so a +/// pathological extension can't blow the stack buffer used for lowercasing; anything +/// that long was never going to match a three-or-four-letter extension anyway. +pub fn isSupportedExtension(extension: []const u8) bool { + var buffer: [16]u8 = undefined; + if (extension.len == 0 or extension.len > buffer.len) return false; + const lower = std.ascii.lowerString(buffer[0..extension.len], extension); + for (supported_extensions) |candidate| { + if (std.mem.eql(u8, candidate, lower)) return true; + } + return false; +} + +/// A byte-for-byte port of `SessionBriefing.slug(for:)`: every character that is not a +/// letter, digit, or dash becomes a dash, then leading/trailing dashes are trimmed. +/// Kept identical on purpose — `NodeMemory.attachmentsDirectory`'s macOS layout names +/// the project component with this slug, and matching it means a human browsing +/// `~/.graphcode/memory` sees the same directory name regardless of which client wrote +/// it. +pub fn projectSlug(allocator: std.mem.Allocator, project_path: []const u8) ![]u8 { + var buffer = try std.ArrayList(u8).initCapacity(allocator, project_path.len); + errdefer buffer.deinit(allocator); + for (project_path) |byte| { + const keep = std.ascii.isAlphanumeric(byte) or byte == '-'; + try buffer.append(allocator, if (keep) byte else '-'); + } + var start: usize = 0; + while (start < buffer.items.len and buffer.items[start] == '-') start += 1; + var end: usize = buffer.items.len; + while (end > start and buffer.items[end - 1] == '-') end -= 1; + const trimmed = try allocator.dupe(u8, buffer.items[start..end]); + buffer.deinit(allocator); + return trimmed; +} + +/// `%GRAPHCODE_SUPPORT_DIR%` when set, otherwise `%USERPROFILE%\.graphcode` — the same +/// default `DaemonClient.zig`'s own `defaultSupportDirectory` resolves, so a node's +/// attachment directory sits beside the memory log the daemon keeps for the same node +/// (`NodeMemory.attachmentsDirectory`). +pub fn supportDirectory(allocator: std.mem.Allocator) ![]u8 { + return supportDirectoryFrom(allocator, std.process.getEnvVarOwned); +} + +const EnvLookup = fn (std.mem.Allocator, []const u8) anyerror![]u8; + +fn supportDirectoryFrom(allocator: std.mem.Allocator, lookup: EnvLookup) ![]u8 { + if (lookup(allocator, "GRAPHCODE_SUPPORT_DIR")) |value| { + return value; + } else |_| {} + const home = lookup(allocator, "USERPROFILE") catch return error.SourceUnreadable; + defer allocator.free(home); + return std.fs.path.join(allocator, &.{ home, ".graphcode" }); +} + +/// Where a draft's attachments land: `\memory\\\attachments`. +/// Deliberately the same directory `NodeMemory.attachmentsDirectory` computes for the +/// eventual node — once the draft is submitted with this same id, the daemon's own +/// `NodeMemory.remove` (fired on node deletion) is what cleans this up; before that, a +/// cancelled draft has to take care of it itself (`discardAll`). +pub fn attachmentsDirectory( + allocator: std.mem.Allocator, + support_dir: []const u8, + project_path: []const u8, + draft_id: []const u8, +) ![]u8 { + const slug = try projectSlug(allocator, project_path); + defer allocator.free(slug); + return std.fs.path.join(allocator, &.{ support_dir, "memory", slug, draft_id, "attachments" }); +} + +/// Copies `source_path` into `dest_dir` as `attachment-<.extension>`, refusing +/// anything unsupported, empty, or over `max_bytes`. Returns the destination's absolute +/// path, owned by `allocator`. Synchronous and whole-file, like macOS's +/// `DraftImageImport.write` — the dialog is open and modal, so nothing else is +/// competing for the bytes in flight. +pub fn ingest( + allocator: std.mem.Allocator, + source_path: []const u8, + dest_dir: []const u8, + number: usize, +) IngestError![]u8 { + const extension = extensionOf(source_path); + if (!isSupportedExtension(extension)) return error.UnsupportedFileType; + var file = std.fs.cwd().openFile(source_path, .{}) catch return error.SourceUnreadable; + defer file.close(); + const stat = file.stat() catch return error.SourceUnreadable; + if (stat.size == 0) return error.EmptyFile; + if (stat.size > max_bytes) return error.FileTooLarge; + const data = file.readToEndAlloc(allocator, max_bytes) catch return error.SourceUnreadable; + defer allocator.free(data); + std.fs.cwd().makePath(dest_dir) catch return error.DestinationUnwritable; + const dest_path = try std.fmt.allocPrint(allocator, "{s}\\attachment-{d}.{s}", .{ dest_dir, number, extension }); + errdefer allocator.free(dest_path); + var dest_file = std.fs.cwd().createFile(dest_path, .{ .truncate = true }) catch + return error.DestinationUnwritable; + defer dest_file.close(); + dest_file.writeAll(data) catch return error.DestinationUnwritable; + return dest_path; +} + +/// Drops a cancelled draft's whole attachment tree. Mirrors macOS +/// `DraftImageImport.discardAll`: the draft id is a node id nothing will ever create, so +/// nothing else owns this directory. Failures (already gone, never created) are +/// swallowed for the same reason macOS's `try?` is — there is no node left to report the +/// failure against. +pub fn discardAll(dest_dir: []const u8) void { + std.fs.cwd().deleteTree(dest_dir) catch {}; +} + +/// The placeholder for the `number`-th attachment, 1-based — byte-identical to macOS +/// `PromptAttachments.token`, since the daemon's shared `PromptAttachments.resolving` +/// is what actually looks for it in the prompt text. +pub fn token(allocator: std.mem.Allocator, number: usize) ![]u8 { + return std.fmt.allocPrint(allocator, "[image #{d}]", .{number}); +} + +/// `text` with the `number`-th placeholder dropped and every later one renumbered, so +/// removing the middle chip of three doesn't leave `[image #3]` pointing at nothing. +/// A direct port of macOS `PromptAttachments.removing(attachment:from:of:)`. +pub fn removing(allocator: std.mem.Allocator, text: []const u8, number: usize, count: usize) ![]u8 { + var current = try allocator.dupe(u8, text); + { + const target = try token(allocator, number); + defer allocator.free(target); + const replaced = try replaceAll(allocator, current, target, ""); + allocator.free(current); + current = replaced; + } + var later = number + 1; + while (later <= count) : (later += 1) { + const from = try token(allocator, later); + defer allocator.free(from); + const to = try token(allocator, later - 1); + defer allocator.free(to); + const replaced = try replaceAll(allocator, current, from, to); + allocator.free(current); + current = replaced; + } + while (std.mem.indexOf(u8, current, " ") != null) { + const replaced = try replaceAll(allocator, current, " ", " "); + allocator.free(current); + current = replaced; + } + const trimmed = std.mem.trim(u8, current, " \t"); + const result = try allocator.dupe(u8, trimmed); + allocator.free(current); + return result; +} + +fn replaceAll(allocator: std.mem.Allocator, haystack: []const u8, needle: []const u8, replacement: []const u8) ![]u8 { + const count = std.mem.replacementSize(u8, haystack, needle, replacement); + const buffer = try allocator.alloc(u8, count); + _ = std.mem.replace(u8, haystack, needle, replacement, buffer); + return buffer; +} + +test "extensionOf reads the extension without the dot" { + try std.testing.expectEqualStrings("png", extensionOf("C:\\Users\\me\\Pictures\\shot.png")); + try std.testing.expectEqualStrings("", extensionOf("C:\\Users\\me\\.gitignore")); + try std.testing.expectEqualStrings("", extensionOf("C:\\Users\\me\\README")); +} + +test "isSupportedExtension accepts images and plain text, case-insensitively" { + try std.testing.expect(isSupportedExtension("PNG")); + try std.testing.expect(isSupportedExtension("jpg")); + try std.testing.expect(isSupportedExtension("md")); + try std.testing.expect(!isSupportedExtension("exe")); + try std.testing.expect(!isSupportedExtension("")); +} + +test "projectSlug matches SessionBriefing.slug's replace-and-trim rule" { + const allocator = std.testing.allocator; + const slug = try projectSlug(allocator, "C:\\work\\graph one"); + defer allocator.free(slug); + try std.testing.expectEqualStrings("C--work-graph-one", slug); + + const trimmed = try projectSlug(allocator, "//C:/work//"); + defer allocator.free(trimmed); + try std.testing.expectEqualStrings("C--work", trimmed); +} + +test "attachmentsDirectory joins support, memory, slug and draft id" { + const allocator = std.testing.allocator; + const dir = try attachmentsDirectory(allocator, "C:\\Users\\me\\.graphcode", "C:\\work\\graph", "11111111-1111-4111-8111-111111111111"); + defer allocator.free(dir); + try std.testing.expectEqualStrings( + "C:\\Users\\me\\.graphcode\\memory\\C--work-graph\\11111111-1111-4111-8111-111111111111\\attachments", + dir, + ); +} + +test "ingest refuses unsupported types, empty files and oversized files" { + const allocator = std.testing.allocator; + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + + try tmp.dir.writeFile(.{ .sub_path = "note.exe", .data = "hello" }); + const exe_path = try tmp.dir.realpathAlloc(allocator, "note.exe"); + defer allocator.free(exe_path); + const dest_dir = try tmp.dir.realpathAlloc(allocator, "."); + defer allocator.free(dest_dir); + const attachments_dir = try std.fs.path.join(allocator, &.{ dest_dir, "attachments" }); + defer allocator.free(attachments_dir); + + try std.testing.expectError(error.UnsupportedFileType, ingest(allocator, exe_path, attachments_dir, 1)); + + try tmp.dir.writeFile(.{ .sub_path = "empty.txt", .data = "" }); + const empty_path = try tmp.dir.realpathAlloc(allocator, "empty.txt"); + defer allocator.free(empty_path); + try std.testing.expectError(error.EmptyFile, ingest(allocator, empty_path, attachments_dir, 1)); + + try tmp.dir.writeFile(.{ .sub_path = "note.txt", .data = "hello there" }); + const ok_path = try tmp.dir.realpathAlloc(allocator, "note.txt"); + defer allocator.free(ok_path); + const written = try ingest(allocator, ok_path, attachments_dir, 1); + defer allocator.free(written); + try std.testing.expect(std.mem.endsWith(u8, written, "attachment-1.txt")); + const copied = try std.fs.cwd().readFileAlloc(allocator, written, 4096); + defer allocator.free(copied); + try std.testing.expectEqualStrings("hello there", copied); +} + +test "discardAll removes the whole attachment tree" { + const allocator = std.testing.allocator; + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + try tmp.dir.makePath("draft-dir\\attachments"); + try tmp.dir.writeFile(.{ .sub_path = "draft-dir\\attachments\\attachment-1.png", .data = "x" }); + const draft_dir = try tmp.dir.realpathAlloc(allocator, "draft-dir"); + defer allocator.free(draft_dir); + discardAll(draft_dir); + try std.testing.expectError(error.FileNotFound, tmp.dir.access("draft-dir", .{})); +} + +test "removing drops the numbered token and renumbers the rest" { + const allocator = std.testing.allocator; + const result = try removing(allocator, "before [image #2] after [image #3]", 2, 3); + defer allocator.free(result); + try std.testing.expectEqualStrings("before after [image #2]", result); +} + +test "token matches macOS PromptAttachments.token exactly" { + const allocator = std.testing.allocator; + const value = try token(allocator, 3); + defer allocator.free(value); + try std.testing.expectEqualStrings("[image #3]", value); +} diff --git a/graphcode-windows/src/FilePicker.c b/graphcode-windows/src/FilePicker.c new file mode 100644 index 00000000..ff2f93fd --- /dev/null +++ b/graphcode-windows/src/FilePicker.c @@ -0,0 +1,72 @@ +#include "FilePicker.h" + +#include + +// Multi-select counterpart to FolderPicker.c's `graphcode_pick_folder`: same +// IFileOpenDialog/COM lifecycle, but FOS_ALLOWMULTISELECT plus a filter restricted to +// the extensions `DraftAttachments.zig`'s `isSupportedExtension` accepts, so a user +// can't pick a file the ingestion step is only going to reject afterwards. +int graphcode_pick_files(HWND owner, wchar_t *buffer, DWORD stride, DWORD max_files) { + HRESULT initialized = CoInitializeEx(NULL, COINIT_APARTMENTTHREADED); + if (FAILED(initialized) && initialized != RPC_E_CHANGED_MODE) return -1; + + IFileOpenDialog *dialog = NULL; + HRESULT result = CoCreateInstance( + &CLSID_FileOpenDialog, NULL, CLSCTX_INPROC_SERVER, + &IID_IFileOpenDialog, (void **)&dialog); + if (FAILED(result)) { + if (SUCCEEDED(initialized)) CoUninitialize(); + return -1; + } + + DWORD options = 0; + dialog->lpVtbl->GetOptions(dialog, &options); + dialog->lpVtbl->SetOptions( + dialog, options | FOS_ALLOWMULTISELECT | FOS_FORCEFILESYSTEM | FOS_FILEMUSTEXIST); + dialog->lpVtbl->SetTitle(dialog, L"Attach files"); + + static const COMDLG_FILTERSPEC filters[] = { + {L"Supported attachments", + L"*.png;*.jpg;*.jpeg;*.gif;*.heic;*.webp;*.tiff;*.tif;*.bmp;*.txt;*.md;*.log;*." + L"json;*.csv;*.pdf"}, + {L"All files", L"*.*"}, + }; + dialog->lpVtbl->SetFileTypes(dialog, ARRAYSIZE(filters), filters); + + result = dialog->lpVtbl->Show(dialog, owner); + if (result == HRESULT_FROM_WIN32(ERROR_CANCELLED)) { + dialog->lpVtbl->Release(dialog); + if (SUCCEEDED(initialized)) CoUninitialize(); + return 0; + } + if (FAILED(result)) { + dialog->lpVtbl->Release(dialog); + if (SUCCEEDED(initialized)) CoUninitialize(); + return -1; + } + + int count = 0; + IShellItemArray *items = NULL; + result = dialog->lpVtbl->GetResults(dialog, &items); + if (SUCCEEDED(result)) { + DWORD item_count = 0; + items->lpVtbl->GetCount(items, &item_count); + DWORD limit = item_count < max_files ? item_count : max_files; + for (DWORD i = 0; i < limit; i++) { + IShellItem *item = NULL; + if (FAILED(items->lpVtbl->GetItemAt(items, i, &item))) continue; + PWSTR path = NULL; + if (SUCCEEDED(item->lpVtbl->GetDisplayName(item, SIGDN_FILESYSPATH, &path)) && + path != NULL) { + lstrcpynW(buffer + (size_t)i * stride, path, (int)stride); + CoTaskMemFree(path); + count++; + } + item->lpVtbl->Release(item); + } + items->lpVtbl->Release(items); + } + dialog->lpVtbl->Release(dialog); + if (SUCCEEDED(initialized)) CoUninitialize(); + return count; +} diff --git a/graphcode-windows/src/FilePicker.h b/graphcode-windows/src/FilePicker.h new file mode 100644 index 00000000..386e1f92 --- /dev/null +++ b/graphcode-windows/src/FilePicker.h @@ -0,0 +1,9 @@ +#pragma once + +#include + +// Fills `buffer` with up to `max_files` selected paths, each occupying its own +// `stride`-wide (in wchar_t units), null-terminated slot: file i's path starts at +// `buffer + i * stride`. Returns the number of paths written, 0 if the user cancelled, +// or -1 on failure. +int graphcode_pick_files(HWND owner, wchar_t *buffer, DWORD stride, DWORD max_files); diff --git a/graphcode-windows/src/Forms.zig b/graphcode-windows/src/Forms.zig index 959712e1..57f6b881 100644 --- a/graphcode-windows/src/Forms.zig +++ b/graphcode-windows/src/Forms.zig @@ -26,6 +26,21 @@ pub const NodeDraft = struct { copilot_permissions: []const u8 = "allowEverything", briefing_enabled: bool = true, activity_enabled: bool = false, + /// The id this node will be created with, chosen by the client before the New Node + /// dialog opens rather than by `DaemonClient.sendCreateNodeDraft` at send time — the + /// same reason Swift `NodeDraft.id` is client-chosen (see that type's doc comment): + /// an attachment written while the dialog is still open has to be filed under the + /// id the node will actually carry. Empty means "let the client generate one at send + /// time", which is every draft that never touched the attachments field. + node_id: []const u8 = "", + /// Paths already written to disk under `node_id`'s attachment directory + /// (`DraftAttachments.attachmentsDirectory`) — the Windows equivalent of Swift + /// `NodeDraft.attachments`. Bounded at `DraftAttachments.max_attachments`, unlike the + /// macOS list, because this is a fixed-size native form field rather than a + /// scrolling SwiftUI stack. + attachment_paths: [8][]const u8 = .{&.{}} ** 8, + attachment_ids: [8][]const u8 = .{&.{}} ** 8, + attachment_count: usize = 0, pub fn deinit(self: *NodeDraft, allocator: std.mem.Allocator) void { freeSlice(allocator, self.title); freeSlice(allocator, self.loop_type); @@ -44,9 +59,35 @@ pub const NodeDraft = struct { freeSlice(allocator, self.worktree_branch); freeSlice(allocator, self.subgraph_json); freeSlice(allocator, self.created_by); + freeSlice(allocator, self.node_id); + for (self.attachment_paths[0..self.attachment_count]) |path| freeSlice(allocator, path); + for (self.attachment_ids[0..self.attachment_count]) |id| freeSlice(allocator, id); } }; +/// A `[[0-9a-f]{8}-...]` version-4-shaped id, generated the same way +/// `DaemonClient.zig`'s own `makeRequestID` does — a nanosecond timestamp rather than a +/// cryptographic random source, because these ids only ever need to be unique within one +/// running client, never unguessable. Exposed here (rather than kept private to +/// `DaemonClient.zig`) so a draft's id can be chosen before its dialog opens, which is +/// what lets an attachment picked mid-dialog be filed under the id the node will +/// actually carry. +/// +/// Mixed with a process-lifetime counter, not the timestamp alone: two calls close +/// enough together can land on the same nanosecond reading on lower-resolution clocks, +/// which would hand two different attachment directories the same name. +var draft_id_sequence = std.atomic.Value(u64).init(0); + +pub fn generateDraftId(buffer: *[36]u8) void { + const timestamp: u64 = @intCast(std.time.nanoTimestamp()); + const sequence = draft_id_sequence.fetchAdd(1, .monotonic); + _ = std.fmt.bufPrint( + buffer, + "00000000-0000-4000-8000-{x:0>12}", + .{(timestamp ^ sequence) & 0xffffffffffff}, + ) catch unreachable; +} + pub const EdgeDraft = struct { from: []const u8, to: []const u8, @@ -124,6 +165,7 @@ pub const FormError = error{ MissingFirstInstruction, MissingTriggerPrompt, EmptyJumpQuery, + TooManyAttachments, }; pub const untitled_fallback = "New Loop"; @@ -173,6 +215,10 @@ pub fn validateNode(draft: NodeDraft) FormError!void { return error.InvalidWorktree; if (draft.subgraph_json.len != 0) try validateSubgraphJson(draft.subgraph_json); if (draft.created_by.len != 0 and !isUuid(draft.created_by)) return error.InvalidCreatedBy; + // `DraftAttachments.max_attachments` — kept as a literal rather than an import so + // this validator has no dependency on the file-picker/ingestion module; both sides + // agree on 8 because that's the fixed-size array `NodeDraft.attachment_paths` is. + if (draft.attachment_count > 8) return error.TooManyAttachments; } pub fn validateSubgraphJson(value: []const u8) FormError!void { @@ -690,6 +736,19 @@ test "node and edge forms reject invalid drafts explicitly" { try validateNode(.{ .title = "Composite", .loop_type = "composite", .subgraph_json = "{\"id\":\"33333333-3333-4333-8333-333333333333\",\"project\":{\"path\":\"C:\\\\work\\\\subgraph\",\"name\":\"subgraph\",\"lastOpenedAt\":1767225600},\"nodes\":[],\"edges\":[]}", .created_by = "11111111-1111-4111-8111-111111111111" }); try std.testing.expectError(error.InvalidSubgraph, validateNode(.{ .title = "Composite", .loop_type = "composite", .subgraph_json = "{\"nodes\":[]}" })); try std.testing.expectError(error.InvalidCreatedBy, validateNode(.{ .title = "Loop", .created_by = "not-a-uuid" })); + var over_capacity = NodeDraft{ .title = "Loop", .attachment_count = 9 }; + try std.testing.expectError(error.TooManyAttachments, validateNode(over_capacity)); + over_capacity.attachment_count = 0; +} + +test "generateDraftId produces a version-4-shaped, distinct id each call" { + var first: [36]u8 = undefined; + var second: [36]u8 = undefined; + generateDraftId(&first); + generateDraftId(&second); + try std.testing.expect(isUuid(&first)); + try std.testing.expect(isUuid(&second)); + try std.testing.expect(!std.mem.eql(u8, &first, &second)); } test "node updates preserve unchanged fields and allow stall clear sentinel" { diff --git a/graphcode-windows/src/NativeForms.zig b/graphcode-windows/src/NativeForms.zig index a7a3bbf8..9730dad8 100644 --- a/graphcode-windows/src/NativeForms.zig +++ b/graphcode-windows/src/NativeForms.zig @@ -1,10 +1,13 @@ const std = @import("std"); const Forms = @import("Forms.zig"); +const DraftAttachments = @import("DraftAttachments.zig"); const WorktreeStatus = @import("WorktreeStatus.zig"); const Tokens = @import("DesignTokens.zig"); const Win32 = @import("Win32.zig"); const c = Win32.c; +extern fn graphcode_pick_files(owner: c.HWND, buffer: [*]u16, stride: c.DWORD, max_files: c.DWORD) callconv(.c) c_int; + const DialogState = struct { allocator: std.mem.Allocator, kind: Kind, @@ -38,10 +41,29 @@ const DialogState = struct { template_options: []const []const u8 = &.{}, templates_available: bool = false, template_requested: bool = false, + // Attachments live outside the fixed-index field system entirely (see the + // "Attachments" comment above `createAttachmentsSection`): they are the one node + // field with a variable-length, user-editable list of entries rather than a single + // scalar value, and the field-index arrays above are sized/labelled per `Kind` in + // ways that assume one value per index. + attachment_project_path: []const u8 = "", + attachment_draft_id: []const u8 = "", + attachment_dir: []u8 = &.{}, + attachment_names: [DraftAttachments.max_attachments][]u8 = .{&.{}} ** DraftAttachments.max_attachments, + attachment_paths: [DraftAttachments.max_attachments][]u8 = .{&.{}} ** DraftAttachments.max_attachments, + attachment_ids: [DraftAttachments.max_attachments][]u8 = .{&.{}} ** DraftAttachments.max_attachments, + attachment_count: usize = 0, + attachment_label: c.HWND = null, + attachment_listbox: c.HWND = null, + attachment_attach_button: c.HWND = null, + attachment_remove_button: c.HWND = null, + attachment_help: c.HWND = null, }; const max_tiles = 8; const tile_base_id = 9600; +const attachment_attach_id = 4; +const attachment_remove_id = 5; const Kind = enum { node, edge, update, settings, jump, template_picker, worktree_policy, worktree_sweep }; const InputKind = enum { edit, readonly, combo, checkbox, tiles }; @@ -171,9 +193,11 @@ fn applyModalCommand(state: *DialogState, command: ModalCommand) void { pub fn node( parent: c.HWND, allocator: std.mem.Allocator, + project_path: []const u8, + draft_id: []const u8, initial: Forms.NodeDraft, ) !?Forms.NodeDraft { - return switch (try nodeWithTemplates(parent, allocator, initial, false)) { + return switch (try nodeWithTemplates(parent, allocator, project_path, draft_id, initial, false)) { .draft => |draft| draft, .cancelled, .templates => null, }; @@ -190,6 +214,8 @@ pub const NodeResult = union(enum) { pub fn nodeWithTemplates( parent: c.HWND, allocator: std.mem.Allocator, + project_path: []const u8, + draft_id: []const u8, initial: Forms.NodeDraft, templates_available: bool, ) !NodeResult { @@ -200,7 +226,16 @@ pub fn nodeWithTemplates( .parent = parent, .templates_available = templates_available, }; + state.attachment_project_path = project_path; + state.attachment_draft_id = draft_id; + var attachments_transferred = false; defer { + // A cancelled dialog leaves nothing behind for the daemon to clean up — the + // draft id it was staged under is never going to become a real node — so the + // client has to take the same responsibility macOS's `cancelNodeForm` does. + if (!state.result and !attachments_transferred and state.attachment_dir.len != 0) + DraftAttachments.discardAll(state.attachment_dir); + freeAttachmentState(state); freeValues(state); allocator.destroy(state); } @@ -226,11 +261,19 @@ pub fn nodeWithTemplates( state.values[18] = try allocator.dupe(u8, initial.subgraph_json); state.values[19] = try allocator.dupe(u8, initial.created_by); for (0..20) |index| state.initial_values[index] = try allocator.dupe(u8, state.values[index]); + try restoreStagedAttachments(state, initial); if (!(try show(state, "Create or edit node", &.{}))) { if (!state.template_requested) return .cancelled; - return .{ .templates = try buildNodeDraftUnchecked(allocator, &state.values, initial) }; + const draft = try buildNodeDraftUnchecked(allocator, state, initial); + attachments_transferred = true; + return .{ .templates = draft }; } - return .{ .draft = try buildNodeDraft(allocator, &state.values, initial) }; + return .{ .draft = buildNodeDraft(allocator, state, initial) catch |err| { + // The user pressed Create, but validation rejected the draft, so no node will + // claim this staged directory. + if (state.attachment_dir.len != 0) DraftAttachments.discardAll(state.attachment_dir); + return err; + } }; } /// A native, keyboard-searchable list of saved templates. The editable combo @@ -255,12 +298,33 @@ pub fn templatePicker( return selected; } +fn restoreStagedAttachments(state: *DialogState, initial: Forms.NodeDraft) !void { + if (initial.attachment_count == 0) return; + const support = try DraftAttachments.supportDirectory(state.allocator); + defer state.allocator.free(support); + state.attachment_dir = try DraftAttachments.attachmentsDirectory( + state.allocator, + support, + state.attachment_project_path, + state.attachment_draft_id, + ); + for (0..initial.attachment_count) |index| { + state.attachment_paths[index] = try state.allocator.dupe(u8, initial.attachment_paths[index]); + state.attachment_ids[index] = try state.allocator.dupe(u8, initial.attachment_ids[index]); + state.attachment_names[index] = try state.allocator.dupe( + u8, + std.fs.path.basename(initial.attachment_paths[index]), + ); + } + state.attachment_count = initial.attachment_count; +} + fn buildNodeDraft( allocator: std.mem.Allocator, - values: []const []u8, + state: *const DialogState, initial: Forms.NodeDraft, ) !Forms.NodeDraft { - var result = try buildNodeDraftUnchecked(allocator, values, initial); + var result = try buildNodeDraftUnchecked(allocator, state, initial); errdefer result.deinit(allocator); try Forms.validateNode(result); return result; @@ -268,9 +332,10 @@ fn buildNodeDraft( fn buildNodeDraftUnchecked( allocator: std.mem.Allocator, - values: []const []u8, + state: *const DialogState, initial: Forms.NodeDraft, ) !Forms.NodeDraft { + const values = &state.values; const goal_based = std.mem.eql(u8, values[1], "goalBased"); const poll_interval = if (goal_based) parseRequiredFloat(values[8]) catch return error.InvalidNumericInput @@ -306,6 +371,19 @@ fn buildNodeDraftUnchecked( result.copilot_permissions = initial.copilot_permissions; result.briefing_enabled = initial.briefing_enabled; result.activity_enabled = initial.activity_enabled; + // Only carried when at least one file was staged: an unattached node keeps the + // legacy empty `node_id`, so `DaemonClient.sendCreateNodeDraft` still generates a + // fresh one at send time exactly as it always has, and the dialog's would-be draft + // directory (never created, since nothing was ever ingested into it) is simply + // abandoned rather than referenced by a node that has no reason to expect it. + if (state.attachment_count != 0) { + result.node_id = try allocator.dupe(u8, state.attachment_draft_id); + for (0..state.attachment_count) |index| { + result.attachment_paths[index] = try allocator.dupe(u8, state.attachment_paths[index]); + result.attachment_ids[index] = try allocator.dupe(u8, state.attachment_ids[index]); + } + result.attachment_count = state.attachment_count; + } return result; } @@ -984,6 +1062,7 @@ fn windowProc(hwnd: c.HWND, message: c.UINT, wparam: c.WPARAM, lparam: c.LPARAM) } else { createStatic(safe_hwnd, value, formIntro(value.kind), 18, 12, 530, 34, &value.intro); for (0..value.field_count) |index| createField(safe_hwnd, value, index); + if (value.kind == .node) createAttachmentsSection(safe_hwnd, value); createStatic(safe_hwnd, value, "", 18, 0, 320, 34, &value.validation); layoutForm(safe_hwnd, value); } @@ -1119,6 +1198,15 @@ fn windowProc(hwnd: c.HWND, message: c.UINT, wparam: c.WPARAM, lparam: c.LPARAM) applyModalCommand(value, .cancel); return 0; } + if (value.kind == .node and command == attachment_attach_id and notification == c.BN_CLICKED) { + attachFiles(safe_hwnd, value); + layoutForm(safe_hwnd, value); + return 0; + } + if (value.kind == .node and command == attachment_remove_id and notification == c.BN_CLICKED) { + removeSelectedAttachment(value); + return 0; + } }, c.WM_CLOSE => { applyModalCommand(value, .close); @@ -1164,6 +1252,250 @@ fn inputControlHeight(kind: InputKind) i32 { return if (kind == .combo) 180 else 24; } +// Attachments: unlike every other node field, this one is a variable-length list the +// user builds up by repeatedly invoking a native file picker, not a single scalar bound +// to `state.values[index]` — so it is laid out as its own section appended after the +// generic field loop (`layoutForm`/`contentHeight`) rather than folded into the +// fixed-index field system `createField`/`InputKind` drive everything else through. +// This keeps every existing field index (and the worktree/subgraph/createdBy +// pass-through slots at 14-19) completely untouched. +const attachment_section_height: i32 = 132; +const attachment_listbox_id = 6; + +fn attachmentsVisible(state: *const DialogState) bool { + return state.kind == .node and !std.mem.eql(u8, state.values[1], "proactive"); +} + +/// Which node field a `[image #N]` placeholder is inserted into/removed from — the +/// one free-text field actually shown for the loop type currently selected. Composite +/// ("proactive") loops have no such field, matching macOS hiding attachments entirely +/// for that loop type. +fn briefFieldIndex(state: *const DialogState) ?usize { + if (std.mem.eql(u8, state.values[1], "turnBased")) return 4; + if (std.mem.eql(u8, state.values[1], "timeBased")) return 3; + if (std.mem.eql(u8, state.values[1], "goalBased")) return 6; + return null; +} + +fn createAttachmentsSection(hwnd: c.HWND, state: *DialogState) void { + createStatic(hwnd, state, "Attachments", 18, 0, 530, 18, &state.attachment_label); + state.attachment_listbox = c.CreateWindowExW( + c.WS_EX_CLIENTEDGE, + std.unicode.utf8ToUtf16LeStringLiteral("LISTBOX").ptr, + null, + @as(c.DWORD, @intCast(c.WS_CHILD)) | @as(c.DWORD, @intCast(c.WS_VISIBLE)) | + @as(c.DWORD, @intCast(c.WS_TABSTOP)) | @as(c.DWORD, @intCast(c.WS_VSCROLL)) | + @as(c.DWORD, @intCast(c.LBS_NOTIFY)), + 18, + 0, + 392, + 84, + hwnd, + childId(attachment_listbox_id), + c.GetModuleHandleW(null), + null, + ); + state.attachment_attach_button = createButtonLabelled(hwnd, "Attach…", attachment_attach_id, 422, 0, 126, 26); + state.attachment_remove_button = createButtonLabelled(hwnd, "Remove", attachment_remove_id, 422, 30, 126, 26); + createStatic( + hwnd, + state, + "Up to 8 files, 10 MB each. Copied into node storage when you press Create.", + 18, + 0, + 530, + 18, + &state.attachment_help, + ); +} + +fn createButtonLabelled(hwnd: c.HWND, text: []const u8, id: usize, x: i32, y: i32, width: i32, height: i32) c.HWND { + const wide = utf8ToWideZ(std.heap.c_allocator, text) catch return null; + defer std.heap.c_allocator.free(wide); + return c.CreateWindowExW( + 0, + std.unicode.utf8ToUtf16LeStringLiteral("BUTTON").ptr, + wide.ptr, + c.WS_CHILD | c.WS_VISIBLE | c.WS_TABSTOP, + x, + y, + width, + height, + hwnd, + childId(id), + c.GetModuleHandleW(null), + null, + ); +} + +fn layoutAttachmentsSection(state: *DialogState, top: i32) void { + const shown = attachmentsVisible(state); + const command = if (shown) c.SW_SHOW else c.SW_HIDE; + for ([_]c.HWND{ state.attachment_label, state.attachment_listbox, state.attachment_attach_button, state.attachment_remove_button, state.attachment_help }) |control| + _ = c.ShowWindow(control, command); + if (!shown) return; + _ = c.MoveWindow(state.attachment_label, 18, top, 530, 18, 1); + _ = c.MoveWindow(state.attachment_listbox, 18, top + 18, 392, 84, 1); + _ = c.MoveWindow(state.attachment_attach_button, 422, top + 18, 126, 26, 1); + _ = c.MoveWindow(state.attachment_remove_button, 422, top + 48, 126, 26, 1); + _ = c.MoveWindow(state.attachment_help, 18, top + 106, 530, 18, 1); +} + +fn refreshAttachmentListbox(state: *DialogState) void { + if (state.attachment_listbox == null) return; + _ = c.SendMessageW(state.attachment_listbox, c.LB_RESETCONTENT, 0, 0); + for (0..state.attachment_count) |index| { + const size = fileSizeBytes(state.attachment_paths[index]); + const size_text = WorktreeStatus.sizeText(state.allocator, size) catch continue; + defer state.allocator.free(size_text); + const line = std.fmt.allocPrint(state.allocator, "{s} ({s})", .{ state.attachment_names[index], size_text }) catch continue; + defer state.allocator.free(line); + const wide = utf8ToWideZ(state.allocator, line) catch continue; + defer state.allocator.free(wide); + _ = c.SendMessageW(state.attachment_listbox, c.LB_ADDSTRING, 0, @intCast(@intFromPtr(wide.ptr))); + } +} + +fn fileSizeBytes(path: []const u8) u64 { + const file = std.fs.cwd().openFile(path, .{}) catch return 0; + defer file.close(); + const stat = file.stat() catch return 0; + return stat.size; +} + +fn ensureAttachmentsDirectory(state: *DialogState) ![]const u8 { + if (state.attachment_dir.len == 0) { + const support = try DraftAttachments.supportDirectory(state.allocator); + defer state.allocator.free(support); + state.attachment_dir = try DraftAttachments.attachmentsDirectory( + state.allocator, + support, + state.attachment_project_path, + state.attachment_draft_id, + ); + } + return state.attachment_dir; +} + +fn attachmentErrorReason(err: DraftAttachments.IngestError) []const u8 { + return switch (err) { + error.UnsupportedFileType => "That file type isn't supported for attachments.", + error.FileTooLarge => "That file is larger than the 10 MB attachment limit.", + error.EmptyFile => "That file is empty.", + error.SourceUnreadable => "That file couldn't be read.", + error.DestinationUnwritable => "Unable to save the attachment.", + error.TooManyAttachments => "Up to 8 attachments per node.", + error.OutOfMemory => "Out of memory while attaching the file.", + }; +} + +fn insertAttachmentToken(state: *DialogState, number: usize) void { + const index = briefFieldIndex(state) orelse return; + if (state.edits[index] == null) return; + const placeholder = DraftAttachments.token(state.allocator, number) catch return; + defer state.allocator.free(placeholder); + var buffer: [8192]u16 = undefined; + const length = c.GetWindowTextW(state.edits[index], &buffer, @intCast(buffer.len)); + const current = std.unicode.utf16LeToUtf8Alloc(state.allocator, buffer[0..@intCast(length)]) catch return; + defer state.allocator.free(current); + const trimmed = std.mem.trim(u8, current, " \t\r\n"); + const next = if (trimmed.len == 0) + state.allocator.dupe(u8, placeholder) catch return + else + std.fmt.allocPrint(state.allocator, "{s} {s}", .{ trimmed, placeholder }) catch return; + defer state.allocator.free(next); + const wide = utf8ToWideZ(state.allocator, next) catch return; + defer state.allocator.free(wide); + _ = c.SetWindowTextW(state.edits[index], wide.ptr); +} + +fn removeAttachmentToken(state: *DialogState, number: usize) void { + const index = briefFieldIndex(state) orelse return; + if (state.edits[index] == null) return; + var buffer: [8192]u16 = undefined; + const length = c.GetWindowTextW(state.edits[index], &buffer, @intCast(buffer.len)); + const current = std.unicode.utf16LeToUtf8Alloc(state.allocator, buffer[0..@intCast(length)]) catch return; + defer state.allocator.free(current); + const updated = DraftAttachments.removing(state.allocator, current, number, state.attachment_count) catch return; + defer state.allocator.free(updated); + const wide = utf8ToWideZ(state.allocator, updated) catch return; + defer state.allocator.free(wide); + _ = c.SetWindowTextW(state.edits[index], wide.ptr); +} + +/// Runs the native multi-select picker, then ingests every path it returned in order — +/// each success adds one `attachment-.` file plus a `[image #n]` token in the +/// brief field; each failure surfaces its reason in the validation line without +/// aborting the rest of the batch. +fn attachFiles(hwnd: c.HWND, state: *DialogState) void { + if (state.attachment_count >= DraftAttachments.max_attachments) { + setStaticText(state, state.validation, "Up to 8 attachments per node."); + return; + } + const remaining = DraftAttachments.max_attachments - state.attachment_count; + const stride: usize = 260; + const buffer = state.allocator.alloc(u16, remaining * stride) catch return; + defer state.allocator.free(buffer); + @memset(buffer, 0); + const picked = graphcode_pick_files(hwnd, buffer.ptr, @intCast(stride), @intCast(remaining)); + if (picked <= 0) return; + const dir = ensureAttachmentsDirectory(state) catch { + setStaticText(state, state.validation, "Unable to prepare attachment storage."); + return; + }; + var added = false; + var index: usize = 0; + while (index < @as(usize, @intCast(picked)) and state.attachment_count < DraftAttachments.max_attachments) : (index += 1) { + const slot = buffer[index * stride .. index * stride + stride]; + const length = std.mem.indexOfScalar(u16, slot, 0) orelse slot.len; + const path_utf8 = std.unicode.utf16LeToUtf8Alloc(state.allocator, slot[0..length]) catch continue; + defer state.allocator.free(path_utf8); + const number = state.attachment_count + 1; + const dest_path = DraftAttachments.ingest(state.allocator, path_utf8, dir, number) catch |err| { + setStaticText(state, state.validation, attachmentErrorReason(err)); + continue; + }; + var id_buffer: [36]u8 = undefined; + Forms.generateDraftId(&id_buffer); + const id = state.allocator.dupe(u8, &id_buffer) catch { + state.allocator.free(dest_path); + continue; + }; + const name = state.allocator.dupe(u8, std.fs.path.basename(path_utf8)) catch { + state.allocator.free(dest_path); + state.allocator.free(id); + continue; + }; + state.attachment_paths[state.attachment_count] = dest_path; + state.attachment_ids[state.attachment_count] = id; + state.attachment_names[state.attachment_count] = name; + state.attachment_count += 1; + added = true; + insertAttachmentToken(state, number); + } + if (added) refreshAttachmentListbox(state); +} + +fn removeSelectedAttachment(state: *DialogState) void { + if (state.attachment_listbox == null) return; + const selected = c.SendMessageW(state.attachment_listbox, c.LB_GETCURSEL, 0, 0); + if (selected < 0) return; + const index: usize = @intCast(selected); + if (index >= state.attachment_count) return; + removeAttachmentToken(state, index + 1); + state.allocator.free(state.attachment_names[index]); + state.allocator.free(state.attachment_paths[index]); + state.allocator.free(state.attachment_ids[index]); + var i = index; + while (i + 1 < state.attachment_count) : (i += 1) { + state.attachment_names[i] = state.attachment_names[i + 1]; + state.attachment_paths[i] = state.attachment_paths[i + 1]; + state.attachment_ids[i] = state.attachment_ids[i + 1]; + } + state.attachment_count -= 1; + refreshAttachmentListbox(state); +} + /// Teaching tiles: one owner-drawn, tab-stop BUTTON per loop-type choice, /// painted in `drawTile` as a color-accented card with title + description — /// the same "explain itself" grid LoopTypeChooser.swift uses on macOS, @@ -1347,6 +1679,7 @@ fn layoutForm(hwnd: c.HWND, state: *DialogState) void { } y += rowHeight(state, index); } + if (state.kind == .node) layoutAttachmentsSection(state, y - state.scroll_offset); updateScrollBar(hwnd, state); } @@ -1372,6 +1705,7 @@ fn contentHeight(state: *const DialogState) i32 { for (0..state.field_count) |index| { if (state.visible[index]) y += rowHeight(state, index); } + if (state.kind == .node and attachmentsVisible(state)) y += attachment_section_height; return y + 12; } @@ -1708,6 +2042,13 @@ fn freeValues(state: *DialogState) void { for (&state.display_labels) |value| if (value.len != 0) state.allocator.free(value); } +fn freeAttachmentState(state: *DialogState) void { + for (state.attachment_names[0..state.attachment_count]) |value| state.allocator.free(value); + for (state.attachment_paths[0..state.attachment_count]) |value| state.allocator.free(value); + for (state.attachment_ids[0..state.attachment_count]) |value| state.allocator.free(value); + if (state.attachment_dir.len != 0) state.allocator.free(state.attachment_dir); +} + fn utf8ToWideZ(allocator: std.mem.Allocator, value: []const u8) ![]u16 { const raw = try std.unicode.utf8ToUtf16LeAlloc(allocator, value); defer allocator.free(raw); @@ -1795,12 +2136,12 @@ test "guided choices map human labels to stable wire values" { } test "node draft builder preserves every hidden initial field" { - var values: [20][]u8 = .{@constCast("")} ** 20; - values[1] = @constCast("turnBased"); - values[4] = @constCast("Start here"); - values[5] = @constCast("false"); - values[8] = @constCast("60"); - values[11] = @constCast("maximize"); + var state = DialogState{ .allocator = std.testing.allocator, .kind = .node, .parent = null }; + state.values[1] = @constCast("turnBased"); + state.values[4] = @constCast("Start here"); + state.values[5] = @constCast("false"); + state.values[8] = @constCast("60"); + state.values[11] = @constCast("maximize"); const initial = Forms.NodeDraft{ .title = "before", .worktree_repository = "D:\\repo", @@ -1814,7 +2155,7 @@ test "node draft builder preserves every hidden initial field" { .briefing_enabled = false, .activity_enabled = true, }; - var draft = try buildNodeDraft(std.testing.allocator, &values, initial); + var draft = try buildNodeDraft(std.testing.allocator, &state, initial); defer draft.deinit(std.testing.allocator); try std.testing.expectEqualStrings(initial.worktree_repository, draft.worktree_repository); try std.testing.expectEqualStrings(initial.worktree_id, draft.worktree_id); @@ -1824,16 +2165,85 @@ test "node draft builder preserves every hidden initial field" { try std.testing.expectEqualStrings(initial.claude_permissions, draft.claude_permissions); try std.testing.expectEqual(initial.briefing_enabled, draft.briefing_enabled); try std.testing.expectEqual(initial.activity_enabled, draft.activity_enabled); + try std.testing.expectEqual(@as(usize, 0), draft.attachment_count); + try std.testing.expectEqual(@as(usize, 0), draft.node_id.len); - var hidden_values = values; - hidden_values[8] = @constCast("not-a-number"); - hidden_values[9] = @constCast("also-invalid"); - var hidden_draft = try buildNodeDraft(std.testing.allocator, &hidden_values, initial); + var hidden_state = state; + hidden_state.values[8] = @constCast("not-a-number"); + hidden_state.values[9] = @constCast("also-invalid"); + var hidden_draft = try buildNodeDraft(std.testing.allocator, &hidden_state, initial); defer hidden_draft.deinit(std.testing.allocator); try std.testing.expectEqual(initial.poll_interval_seconds, hidden_draft.poll_interval_seconds); try std.testing.expectEqual(initial.stall_after_seconds, hidden_draft.stall_after_seconds); } +test "node draft builder carries staged attachments and the draft id onto the wire draft" { + var state = DialogState{ .allocator = std.testing.allocator, .kind = .node, .parent = null }; + state.values[1] = @constCast("turnBased"); + state.values[4] = @constCast("look at [image #1]"); + state.values[5] = @constCast("false"); + state.values[8] = @constCast("60"); + state.values[11] = @constCast("maximize"); + state.attachment_draft_id = "11111111-1111-4111-8111-111111111111"; + state.attachment_count = 1; + state.attachment_paths[0] = @constCast("C:\\Users\\me\\.graphcode\\memory\\slug\\11111111-1111-4111-8111-111111111111\\attachments\\attachment-1.png"); + state.attachment_ids[0] = @constCast("aaaaaaaa-1111-4111-8111-111111111111"); + var draft = try buildNodeDraft(std.testing.allocator, &state, .{ .title = "before" }); + defer draft.deinit(std.testing.allocator); + try std.testing.expectEqualStrings("11111111-1111-4111-8111-111111111111", draft.node_id); + try std.testing.expectEqual(@as(usize, 1), draft.attachment_count); + try std.testing.expectEqualStrings(state.attachment_paths[0], draft.attachment_paths[0]); + try std.testing.expectEqualStrings(state.attachment_ids[0], draft.attachment_ids[0]); +} + +test "template handoff retains staged attachments in the unchecked draft" { + var state = DialogState{ .allocator = std.testing.allocator, .kind = .node, .parent = null }; + state.values[1] = @constCast("turnBased"); + state.values[4] = @constCast("review [image #1]"); + state.values[5] = @constCast("false"); + state.values[8] = @constCast("60"); + state.values[11] = @constCast("maximize"); + state.attachment_draft_id = "11111111-1111-4111-8111-111111111111"; + state.attachment_count = 1; + state.attachment_paths[0] = @constCast("C:\\memory\\project\\11111111-1111-4111-8111-111111111111\\attachments\\attachment-1.png"); + state.attachment_ids[0] = @constCast("aaaaaaaa-1111-4111-8111-111111111111"); + + // The Templates action returns this unchecked draft to App, which applies the + // selected template and reopens the form before the checked Create result. + var handoff = try buildNodeDraftUnchecked(std.testing.allocator, &state, .{ .title = "before" }); + defer handoff.deinit(std.testing.allocator); + try std.testing.expectEqualStrings(state.attachment_draft_id, handoff.node_id); + try std.testing.expectEqual(@as(usize, 1), handoff.attachment_count); + try std.testing.expectEqualStrings(state.attachment_paths[0], handoff.attachment_paths[0]); + try std.testing.expectEqualStrings(state.attachment_ids[0], handoff.attachment_ids[0]); + + var reopened = DialogState{ .allocator = std.testing.allocator, .kind = .node, .parent = null }; + reopened.attachment_project_path = "C:\\project"; + reopened.attachment_draft_id = handoff.node_id; + defer freeAttachmentState(&reopened); + try restoreStagedAttachments(&reopened, handoff); + try std.testing.expectEqual(@as(usize, 1), reopened.attachment_count); + try std.testing.expectEqualStrings(handoff.attachment_paths[0], reopened.attachment_paths[0]); + try std.testing.expectEqualStrings(handoff.attachment_ids[0], reopened.attachment_ids[0]); +} + +test "attachments are hidden for composite loops and mapped to the shown brief field" { + var state = DialogState{ .allocator = undefined, .kind = .node, .parent = null }; + state.values[1] = @constCast("turnBased"); + try std.testing.expect(attachmentsVisible(&state)); + try std.testing.expectEqual(@as(?usize, 4), briefFieldIndex(&state)); + + state.values[1] = @constCast("timeBased"); + try std.testing.expectEqual(@as(?usize, 3), briefFieldIndex(&state)); + + state.values[1] = @constCast("goalBased"); + try std.testing.expectEqual(@as(?usize, 6), briefFieldIndex(&state)); + + state.values[1] = @constCast("proactive"); + try std.testing.expect(!attachmentsVisible(&state)); + try std.testing.expectEqual(@as(?usize, null), briefFieldIndex(&state)); +} + test "conditional graph fields and validation follow selected types" { var node_state = DialogState{ .allocator = undefined, .kind = .node, .parent = null }; node_state.field_count = 14; @@ -1936,7 +2346,7 @@ test "tile rows reserve full teaching-tile height while other rows stay compact" try std.testing.expectEqual(@as(i32, 54), fieldTop(&state, 0).?); try std.testing.expectEqual(@as(i32, 118), fieldTop(&state, 1).?); try std.testing.expectEqual(@as(i32, 118 + tile_row_height), fieldTop(&state, 2).?); - try std.testing.expectEqual(@as(i32, 118 + tile_row_height + 64 + 12), contentHeight(&state)); + try std.testing.expectEqual(@as(i32, 118 + tile_row_height + 64 + attachment_section_height + 12), contentHeight(&state)); } test "blendColor tints toward the overlay color proportionally to strength" { diff --git a/graphcode-windows/src/Wire.zig b/graphcode-windows/src/Wire.zig index 96ce2b48..3fda2d90 100644 --- a/graphcode-windows/src/Wire.zig +++ b/graphcode-windows/src/Wire.zig @@ -327,6 +327,7 @@ pub fn commandGraphCreateNodeFull( const subgraph = try safeSubgraphJson(allocator, draft.subgraph_json); defer allocator.free(subgraph); const created_by = if (Forms.isUuid(draft.created_by)) try quoteJson(allocator, draft.created_by) else try allocator.dupe(u8, "null"); defer allocator.free(created_by); + const attachments = try attachmentsJson(allocator, draft); defer allocator.free(attachments); return std.mem.concat(allocator, u8, &.{ "{\"graphCommand\":{\"projectPath\":", path, ",\"command\":{\"createNode\":{\"_0\":{\"id\":", id, ",\"title\":", title, ",\"loopType\":", lt, ",\"checkDescription\":", check, @@ -334,10 +335,33 @@ pub fn commandGraphCreateNodeFull( ",\"pausesBeforeWritesOnly\":", if (draft.pauses_before_writes_only) "true" else "false", ",\"goal\":", goal, ",\"backend\":", backend, ",\"modelTier\":", tier, ",\"worktree\":", worktree, ",\"subGraph\":", subgraph, ",\"createdBy\":", created_by, + ",\"attachments\":", attachments, "}}}}}", }); } +/// `[{"id":"...","path":"..."}, ...]`, matching Swift `PromptAttachment`'s Codable shape +/// exactly. Always an array (never `null`) so a client old enough to have no attachment +/// UI and a client that ingested zero files are indistinguishable on the wire — both +/// send `[]`, which `NodeDraft`'s decoder already treats the same as a field it never saw. +fn attachmentsJson(allocator: std.mem.Allocator, draft: Forms.NodeDraft) ![]u8 { + var output = std.array_list.Managed(u8).init(allocator); + errdefer output.deinit(); + try output.appendSlice("["); + for (draft.attachment_paths[0..draft.attachment_count], draft.attachment_ids[0..draft.attachment_count], 0..) |path, id, index| { + if (index != 0) try output.appendSlice(","); + const quoted_id = try quoteJson(allocator, id); defer allocator.free(quoted_id); + const quoted_path = try quoteJson(allocator, path); defer allocator.free(quoted_path); + try output.appendSlice("{\"id\":"); + try output.appendSlice(quoted_id); + try output.appendSlice(",\"path\":"); + try output.appendSlice(quoted_path); + try output.appendSlice("}"); + } + try output.appendSlice("]"); + return output.toOwnedSlice(); +} + fn nullableString(allocator: std.mem.Allocator, value: ?[]const u8) ![]u8 { const text = value orelse return allocator.dupe(u8, "null"); if (text.len == 0) return allocator.dupe(u8, "null"); @@ -1179,6 +1203,7 @@ test "typed node and edge forms retain every supported field on the wire" { defer allocator.free(inherited); try std.testing.expect(std.mem.indexOf(u8, inherited, "\"backend\":null") != null); try std.testing.expect(std.mem.indexOf(u8, inherited, "\"createdBy\":null") != null); + try std.testing.expect(std.mem.indexOf(u8, inherited, "\"attachments\":[]") != null); const edge = try commandGraphCreateEdgeFull(allocator, "C:\\work\\graph", "a", "b", .{ .from = "a", .to = "b", @@ -1194,6 +1219,23 @@ test "typed node and edge forms retain every supported field on the wire" { try std.testing.expect(std.mem.indexOf(u8, edge, field) != null); } +test "node drafts with attachments encode PromptAttachment-shaped entries" { + const allocator = std.testing.allocator; + var draft = Forms.NodeDraft{ .title = "With attachments", .first_instruction = "look at [image #1]" }; + draft.attachment_count = 2; + draft.attachment_ids[0] = "aaaaaaaa-1111-4111-8111-111111111111"; + draft.attachment_paths[0] = "C:\\Users\\me\\.graphcode\\memory\\slug\\node\\attachments\\attachment-1.png"; + draft.attachment_ids[1] = "bbbbbbbb-2222-4222-8222-222222222222"; + draft.attachment_paths[1] = "C:\\Users\\me\\.graphcode\\memory\\slug\\node\\attachments\\attachment-2.txt"; + const command = try commandGraphCreateNodeFull(allocator, "C:\\work\\graph", "11111111-1111-4111-8111-111111111111", draft); + defer allocator.free(command); + try std.testing.expect(std.mem.indexOf( + u8, + command, + "\"attachments\":[{\"id\":\"aaaaaaaa-1111-4111-8111-111111111111\",\"path\":\"C:\\\\Users\\\\me\\\\.graphcode\\\\memory\\\\slug\\\\node\\\\attachments\\\\attachment-1.png\"},{\"id\":\"bbbbbbbb-2222-4222-8222-222222222222\",\"path\":\"C:\\\\Users\\\\me\\\\.graphcode\\\\memory\\\\slug\\\\node\\\\attachments\\\\attachment-2.txt\"}]", + ) != null); +} + test "quick chat commands match shared Codable labels" { const allocator = std.testing.allocator; const list = try commandListQuickChats(allocator); diff --git a/windows-tests/GraphCommandInteropTests.swift b/windows-tests/GraphCommandInteropTests.swift index a509db5f..62cb5b8a 100644 --- a/windows-tests/GraphCommandInteropTests.swift +++ b/windows-tests/GraphCommandInteropTests.swift @@ -65,6 +65,7 @@ final class GraphCommandInteropTests: XCTestCase { XCTAssertEqual(draft.id, UUID(uuidString: "22222222-2222-4222-8222-222222222222")) XCTAssertNil(draft.backend) XCTAssertEqual(draft.firstInstruction, "work") + XCTAssertTrue(draft.attachments.isEmpty) let graph = try JSONDecoder().decode(LoopGraph.self, from: fixture("swift-loopgraph-valid.json")) XCTAssertEqual(graph.id, UUID(uuidString: "11111111-1111-4111-8111-111111111111")) @@ -78,6 +79,21 @@ final class GraphCommandInteropTests: XCTestCase { from: Data(#"{"nodes":[],"edges":[]}"#.utf8))) } + func testNodeDraftWithAttachmentsFixtureDecodesToSwiftPromptAttachment() throws { + let draft = try JSONDecoder().decode( + NodeDraft.self, from: fixture("swift-node-draft-with-attachments-valid.json")) + XCTAssertEqual(draft.id, UUID(uuidString: "22222222-2222-4222-8222-222222222222")) + XCTAssertEqual(draft.firstInstruction, "look at [image #1]") + XCTAssertEqual(draft.attachments.count, 1) + XCTAssertEqual(draft.attachments[0].id, UUID(uuidString: "aaaaaaaa-1111-4111-8111-111111111111")) + XCTAssertEqual( + draft.attachments[0].path, + "C:\\Users\\me\\.graphcode\\memory\\my-project\\22222222-2222-4222-8222-222222222222\\attachments\\attachment-1.png") + XCTAssertEqual( + PromptAttachments.resolving(draft.firstInstruction, attachments: draft.attachments), + "look at C:\\Users\\me\\.graphcode\\memory\\my-project\\22222222-2222-4222-8222-222222222222\\attachments\\attachment-1.png") + } + func testPopulatedLoopGraphFixturesDecodeOrRejectInSwift() throws { let graph = try JSONDecoder().decode( LoopGraph.self, from: fixture("swift-loopgraph-populated-valid.json")) From 2129bad62e0cc6b989b3aba5d90cc5d82f98a222 Mon Sep 17 00:00:00 2001 From: Colin Neilens Date: Tue, 22 Sep 2026 15:37:42 -0700 Subject: [PATCH 2/2] Preserve node validation status Keep form validation single-pass while classifying Forms.FormError results at the App boundary, so invalid drafts retain the user-facing validation message rather than looking like dialog creation failed. Signed-off-by: Colin Neilens Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- graphcode-windows/src/App.zig | 51 ++++++++++++++++++++++++++++++++--- 1 file changed, 47 insertions(+), 4 deletions(-) diff --git a/graphcode-windows/src/App.zig b/graphcode-windows/src/App.zig index c3979001..a7f49253 100644 --- a/graphcode-windows/src/App.zig +++ b/graphcode-windows/src/App.zig @@ -1157,8 +1157,8 @@ pub const App = struct { }; defer templates.deinit(); if (templates.templates.items.len == 0) { - var draft = NativeForms.node(self.window.hwnd, self.allocator, path, &draft_id_buffer, initial) catch { - self.setStatus("Unable to open node form"); + var draft = NativeForms.node(self.window.hwnd, self.allocator, path, &draft_id_buffer, initial) catch |err| { + self.setStatus(nodeFormErrorStatus(err)); return; } orelse return; defer draft.deinit(self.allocator); @@ -1187,8 +1187,8 @@ pub const App = struct { var owns_current = false; defer if (owns_current) current.deinit(self.allocator); while (true) { - const result = NativeForms.nodeWithTemplates(self.window.hwnd, self.allocator, path, &draft_id_buffer, current, true) catch { - self.setStatus("Unable to open node form"); + const result = NativeForms.nodeWithTemplates(self.window.hwnd, self.allocator, path, &draft_id_buffer, current, true) catch |err| { + self.setStatus(nodeFormErrorStatus(err)); return; }; switch (result) { @@ -1216,6 +1216,34 @@ pub const App = struct { } } + fn nodeFormErrorStatus(err: anyerror) []const u8 { + return switch (err) { + error.EmptyTitle, + error.MissingSource, + error.MissingTarget, + error.SameEndpoint, + error.UnsupportedLoopType, + error.UnsupportedEdgeKind, + error.UnsupportedEdgeCondition, + error.UnsupportedTransform, + error.UnsupportedBackend, + error.UnsupportedModelTier, + error.UnsupportedMetricDirection, + error.InvalidGoal, + error.InvalidWorktree, + error.InvalidSubgraph, + error.InvalidCreatedBy, + error.InvalidCycleGuard, + error.InvalidNumericInput, + error.MissingFirstInstruction, + error.MissingTriggerPrompt, + error.EmptyJumpQuery, + error.TooManyAttachments, + => "Invalid node form", + else => "Unable to open node form", + }; + } + fn editSelectedNode(self: *App) void { const graph = self.model.graph orelse return; const index = self.model.selectedIndex() orelse return; @@ -5780,6 +5808,21 @@ test "jump matching ranks exact results across projects" { try std.testing.expectEqual(@as(u8, 2), prefix.score); } +test "node form validation errors keep the validation status" { + try std.testing.expectEqualStrings( + "Invalid node form", + App.nodeFormErrorStatus(error.MissingFirstInstruction), + ); + try std.testing.expectEqualStrings( + "Invalid node form", + App.nodeFormErrorStatus(error.TooManyAttachments), + ); + try std.testing.expectEqualStrings( + "Unable to open node form", + App.nodeFormErrorStatus(error.FormCreationFailed), + ); +} + fn runSmokeWorkspaceActions(self: *App) void { const script = self.smoke_workspace_actions; if (script.len == 0) return;