From 3f68708359d4f996b58c72d47613abf746d6b9f6 Mon Sep 17 00:00:00 2001 From: Colin Neilens Date: Sun, 8 Nov 2026 16:28:12 -0700 Subject: [PATCH 1/2] Match new native workspace names to macOS slug rules Use system ICU transliteration and grapheme boundaries for creation-only naming, preserving legacy rename rules and existing workspace identities. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Signed-off-by: Colin Neilens --- graphcode-windows/README.md | 24 +- graphcode-windows/src/NewWorkspaceForm.zig | 44 ++- graphcode-windows/src/WorkspaceLifecycle.zig | 316 ++++++++++++++++++- 3 files changed, 369 insertions(+), 15 deletions(-) diff --git a/graphcode-windows/README.md b/graphcode-windows/README.md index 85dc42e7b..883ea43e3 100644 --- a/graphcode-windows/README.md +++ b/graphcode-windows/README.md @@ -590,9 +590,27 @@ diagram titled with the typed name, and workspace-isolation details. Invalid or colliding names stay inline with Create & Open disabled; an empty draft stays quiet, and Cancel discards it. Ctrl+A selects the complete focused name field without changing ordinary typing or AltGr handling. Creation and launch still -use the existing workspace lifecycle and identity guards. Windows keeps its -existing ASCII normalization and 48-character limit rather than adopting the -macOS transliteration and truncation rules. +use the existing workspace lifecycle and identity guards. + +New workspace names follow macOS's operation order: transliterate to Latin, +strip nonspacing diacritics, lowercase, map characters other than ASCII letters +and numbers to separators, collapse separators, and take the first 32 characters. The cut +can end in a hyphen. Preview, submit-time validation, and creation share this +creation-only rule, including file/directory collisions after transliteration +or truncation. Windows absolute-path validation and filesystem errors still +apply; the Darwin socket-path limit does not apply to Windows named pipes. +The native implementation uses Windows' built-in ICU C API, loaded only from +System32 (the combined DLL is available since Windows 10 1903, before the +Windows 11 baseline and Server 2022/2025 CI hosts). Missing ICU, missing exports, +or failed transforms report an operational error, never an ASCII fallback. +The 27 checked naming fixtures were produced by compiling the actual shared +`Workspace.slug` method with pinned Swift/Foundation 6.3.3. They establish finite +fixture equivalence, not equality for every Unicode character across OS/ICU +versions, nor a live native input or paired macOS visual qualification. +Existing directories retain their original names, paths, identities, and +discovery/activation behavior; nothing is migrated or renamed. Explicit Rename +keeps its existing ASCII normalization and 48-character limit. + Selecting the current workspace does nothing; selecting an identified running workspace restores its exact window rather than the first GraphCode window. `Ctrl+Alt+PageUp` / `Ctrl+Alt+PageDown` and Previous/Next Workspace cycle only diff --git a/graphcode-windows/src/NewWorkspaceForm.zig b/graphcode-windows/src/NewWorkspaceForm.zig index 9394d9fa9..e6b3a18ee 100644 --- a/graphcode-windows/src/NewWorkspaceForm.zig +++ b/graphcode-windows/src/NewWorkspaceForm.zig @@ -22,7 +22,7 @@ pub const State = struct { allocator: std.mem.Allocator, home: []const u8, current_name: []const u8, - validator: Validator = &Lifecycle.validateName, + validator: Validator = &Lifecycle.validateCreationName, draft: []u8 = &.{}, path: []u8 = &.{}, problem: ?[]const u8 = null, @@ -510,7 +510,7 @@ const TestValidation = struct { calls += 1; if (failure) return error.AccessDenied; if (collision) return error.NameTaken; - return Lifecycle.normalizeName(allocator, input); + return Lifecycle.normalizeCreationName(allocator, input); } fn state() State { @@ -580,15 +580,16 @@ test "new workspace validation operational failures propagate" { try std.testing.expectError(error.AccessDenied, state.update("Client Work")); } -test "new workspace name limit follows existing lifecycle without truncation" { +test "new workspace creation name truncates without rejecting the typed title" { var state = TestValidation.state(); defer state.deinit(); - try state.update(&(.{'a'} ** Lifecycle.max_name_length)); + try state.update(&(.{'a'} ** Lifecycle.max_creation_name_length)); try std.testing.expect(state.canCreate()); - try state.update(&(.{'a'} ** (Lifecycle.max_name_length + 1))); - try std.testing.expect(state.problem != null); - try std.testing.expectEqualStrings("Workspace name is too long", state.problem.?); - try std.testing.expect(!state.canCreate()); + try state.update(&(.{'a'} ** (Lifecycle.max_creation_name_length + 1))); + try std.testing.expect(state.problem == null); + try std.testing.expect(state.canCreate()); + try std.testing.expectEqualStrings("C:\\OwnedHome\\.graphcode-" ++ "a" ** 32, state.path); + try std.testing.expectEqual(@as(usize, 33), state.previewName().len); } test "new workspace layout keeps fields and diagram separate at supported DPIs" { @@ -626,17 +627,40 @@ test "new workspace create cancel and editing notifications route without accide try std.testing.expectEqual(CommandAction.ignore, commandAction(9999, 0)); } -test "new workspace presentation preserves Unicode and ampersands without changing directory rules" { +test "new workspace presentation preserves Unicode and ampersands with creation transliteration" { var state = TestValidation.state(); defer state.deinit(); try state.update("Caf\xc3\xa9 R&D"); try std.testing.expectEqualStrings("Caf\xc3\xa9 R&D", state.previewName()); - try std.testing.expectEqualStrings("C:\\OwnedHome\\.graphcode-caf-r-d", state.path); + try std.testing.expectEqualStrings("C:\\OwnedHome\\.graphcode-cafe-r-d", state.path); var result = (try state.submit()).?; defer result.deinit(std.testing.allocator); try std.testing.expectEqualStrings("Caf\xc3\xa9 R&D", result.name); } +test "new workspace production validator preview submit and create agree on Unicode and truncation" { + const allocator = std.testing.allocator; + var temporary = std.testing.tmpDir(.{}); + defer temporary.cleanup(); + const home = try temporary.dir.realpathAlloc(allocator, "."); + defer allocator.free(home); + var state = State{ .allocator = allocator, .home = home, .current_name = "Legacy-" ++ "x" ** 48 }; + defer state.deinit(); + for ([_][]const u8{ "Café Zürich", "東京", "a" ** 31 ++ " bb" }) |input| { + try state.update(input); + try std.testing.expectEqualStrings(input, state.previewName()); + try std.testing.expect(state.canCreate()); + var result = (try state.submit()).?; + defer result.deinit(allocator); + var workspace = try Lifecycle.create(allocator, result.name, home); + defer workspace.deinit(allocator); + try std.testing.expectEqualStrings(workspace.path, state.path); + try std.testing.expect((try state.submit()) == null); + try std.testing.expectEqualStrings("That workspace already exists", state.problem.?); + try std.testing.expectEqualStrings("", state.path); + } +} + test "new workspace state and handoff release every allocation on failure" { const Exercise = struct { fn run(allocator: std.mem.Allocator) !void { diff --git a/graphcode-windows/src/WorkspaceLifecycle.zig b/graphcode-windows/src/WorkspaceLifecycle.zig index 6bb01a79e..b983acbc1 100644 --- a/graphcode-windows/src/WorkspaceLifecycle.zig +++ b/graphcode-windows/src/WorkspaceLifecycle.zig @@ -3,6 +3,7 @@ const std = @import("std"); pub const directory_prefix = ".graphcode-"; pub const default_directory_name = ".graphcode"; pub const max_name_length: usize = 48; +pub const max_creation_name_length: usize = 32; pub const Workspace = struct { name: []const u8, @@ -172,6 +173,134 @@ pub fn normalizeName(allocator: std.mem.Allocator, input: []const u8) ![]u8 { return result.toOwnedSlice(); } +const NamingIcu = struct { + library: std.DynLib, + open: *const fn ([*]const u16, i32, c_int, ?[*]const u16, i32, ?*anyopaque, *i32) callconv(.c) ?*anyopaque, + transform: *const fn (*const anyopaque, [*]u16, *i32, i32, i32, *i32, *i32) callconv(.c) void, + close: *const fn (*anyopaque) callconv(.c) void, + break_open: *const fn (c_int, [*:0]const u8, [*]const u16, i32, *i32) callconv(.c) ?*anyopaque, + break_next: *const fn (*anyopaque) callconv(.c) i32, + break_close: *const fn (*anyopaque) callconv(.c) void, + last_status: i32 = 0, + + fn init() !NamingIcu { + // ICU is an OS component on the supported Windows 11/Server 2022+ targets. + var library = std.DynLib{ .inner = .{ .dll = try std.os.windows.LoadLibraryExW( + std.unicode.utf8ToUtf16LeStringLiteral("icu.dll"), + .load_library_search_system32, + ) } }; + errdefer library.close(); + var result: NamingIcu = undefined; + inline for (.{ .{ "open", "utrans_openU" }, .{ "transform", "utrans_transUChars" }, .{ "close", "utrans_close" }, .{ "break_open", "ubrk_open" }, .{ "break_next", "ubrk_next" }, .{ "break_close", "ubrk_close" } }) |binding| { + @field(result, binding[0]) = library.lookup(@TypeOf(@field(result, binding[0])), binding[1]) orelse + return error.WorkspaceNamingIcuExportMissing; + } + result.library = library; + result.last_status = 0; + return result; + } + + fn latin(self: *NamingIcu, allocator: std.mem.Allocator, input: []const u16) ![]u16 { + const id = std.unicode.utf8ToUtf16LeStringLiteral("Any-Latin; NFD; [:Nonspacing Mark:] Remove; NFC; Lower"); + var status: i32 = 0; + const transliterator = self.open(id, @intCast(id.len), 0, null, 0, null, &status); + self.last_status = status; + const handle = transliterator orelse return error.WorkspaceNamingTransformFailed; + defer self.close(handle); + if (status > 0) return error.WorkspaceNamingTransformFailed; + const max_length = std.math.maxInt(i32); + var capacity = std.math.add(usize, input.len, 1) catch return error.NameTooLong; + while (capacity <= max_length) { + const text = try allocator.alloc(u16, capacity); + defer allocator.free(text); + @memcpy(text[0..input.len], input); + var length: i32 = @intCast(input.len); + var limit = length; + status = 0; + self.transform(handle, text.ptr, &length, @intCast(capacity), 0, &limit, &status); + self.last_status = status; + if (status == 15) { // U_BUFFER_OVERFLOW_ERROR: retry the original input, not partial output. + capacity = @max(capacity * 2, @as(usize, @intCast(@max(length, 0))) + 1); + continue; + } + if (status > 0 or length < 0 or length > capacity) return error.WorkspaceNamingTransformFailed; + return allocator.dupe(u16, text[0..@intCast(length)]); + } + return error.NameTooLong; + } + + fn characters(self: *NamingIcu, plain: []const u16) !*anyopaque { + var status: i32 = 0; + const iterator = self.break_open(0, "", plain.ptr, @intCast(plain.len), &status); + self.last_status = status; + const handle = iterator orelse return error.WorkspaceNamingCharactersFailed; + if (status > 0) { + self.break_close(handle); + return error.WorkspaceNamingCharactersFailed; + } + return handle; + } +}; + +/// Creation follows Workspace.slug's transforms and Character mapping before prefix(32). +/// Legacy normalization remains separate for explicit rename; discovered paths never use either. +pub fn normalizeCreationName(allocator: std.mem.Allocator, input: []const u8) ![]u8 { + if (!std.unicode.utf8ValidateSlice(input)) return error.InvalidName; + var icu = NamingIcu.init() catch |err| { + std.log.err("Workspace Unicode naming ICU could not be loaded: {s}", .{@errorName(err)}); + return err; + }; + defer icu.library.close(); + const utf16 = try std.unicode.utf8ToUtf16LeAlloc(allocator, input); + defer allocator.free(utf16); + const plain = icu.latin(allocator, utf16) catch |err| { + if (err == error.WorkspaceNamingTransformFailed) + std.log.err("Workspace Unicode naming ICU transform failed: {d}", .{icu.last_status}); + return err; + }; + defer allocator.free(plain); + const iterator = icu.characters(plain) catch |err| { + std.log.err("Workspace Unicode naming ICU character iterator failed: {d}", .{icu.last_status}); + return err; + }; + defer icu.break_close(iterator); + var result: [max_creation_name_length]u8 = undefined; + var length: usize = 0; + var start: usize = 0; + var separator = false; + while (true) { + const end_signed = icu.break_next(iterator); + if (end_signed == -1) break; + const end: usize = @intCast(end_signed); + const character = plain[start..end]; + start = end; + if (character.len == 1 and character[0] < 128 and std.ascii.isAlphanumeric(@intCast(character[0]))) { + if (separator and length != 0) { + result[length] = '-'; + length += 1; + if (length == result.len) break; + } + result[length] = @intCast(character[0]); + length += 1; + if (length == result.len) break; + separator = false; + } else { + separator = true; + } + } + if (length == 0) return error.EmptyName; + return allocator.dupe(u8, result[0..length]); +} + +pub fn validateCreationName(allocator: std.mem.Allocator, input: []const u8, home: []const u8) ![]u8 { + const identity = try pathIdentity(allocator, home); + defer allocator.free(identity); + const name = try normalizeCreationName(allocator, input); + errdefer allocator.free(name); + try checkNameAvailable(allocator, name, home); + return name; +} + pub fn validateName( allocator: std.mem.Allocator, input: []const u8, @@ -179,10 +308,15 @@ pub fn validateName( ) ![]u8 { const name = try normalizeName(allocator, input); errdefer allocator.free(name); + try checkNameAvailable(allocator, name, home); + return name; +} + +fn checkNameAvailable(allocator: std.mem.Allocator, name: []const u8, home: []const u8) !void { const path = try workspacePath(allocator, name, home); defer allocator.free(path); std.fs.cwd().access(path, .{}) catch |err| switch (err) { - error.FileNotFound => return name, + error.FileNotFound => return, else => return err, }; return NameError.NameTaken; @@ -197,7 +331,7 @@ pub fn workspacePath( } pub fn create(allocator: std.mem.Allocator, input: []const u8, home: []const u8) !Workspace { - const name = try validateName(allocator, input, home); + const name = try validateCreationName(allocator, input, home); errdefer allocator.free(name); const path = try workspacePath(allocator, name, home); errdefer allocator.free(path); @@ -361,6 +495,184 @@ test "workspace names normalize to safe stable directory suffixes" { try std.testing.expectEqualStrings("caf-z-rich", name); } +test "workspace creation naming parity matches pinned Foundation oracle fixtures" { + const cases = [_]struct { input: []const u8, slug: []const u8 }{ + .{ .input = "", .slug = "" }, + .{ .input = "///", .slug = "" }, + .{ .input = "Client Work", .slug = "client-work" }, + .{ .input = " spaced out ", .slug = "spaced-out" }, + .{ .input = "OSS/side—projects!", .slug = "oss-side-projects" }, + .{ .input = "Café Zürich", .slug = "cafe-zurich" }, + .{ .input = "Cafe\u{301} Zu\u{308}rich", .slug = "cafe-zurich" }, + .{ .input = "Москва", .slug = "moskva" }, + .{ .input = "東京", .slug = "dong-jing" }, + .{ .input = "中文", .slug = "zhong-wen" }, + .{ .input = "Αθήνα", .slug = "athena" }, + .{ .input = "العربية", .slug = "al-rbyt" }, + .{ .input = "नमस्ते", .slug = "namaste" }, + .{ .input = "한국어", .slug = "hangug-eo" }, + .{ .input = "Łódź Straße Æsir Øresund", .slug = "odz-stra-e-sir-resund" }, + .{ .input = "İ I ı", .slug = "i-i" }, + .{ .input = "ABC 123", .slug = "" }, + .{ .input = "work👩‍💻space", .slug = "work-space" }, + .{ .input = "a\u{93e}b", .slug = "aab" }, + .{ .input = "a\u{200d}b", .slug = "b" }, + .{ .input = "1️⃣ team", .slug = "team" }, + .{ .input = "CON", .slug = "con" }, + .{ .input = "..\\..//Outside workspace", .slug = "outside-workspace" }, + .{ .input = "a" ** 31 ++ " bb", .slug = "a" ** 31 ++ "-" }, + .{ .input = "a" ** 32 ++ "bb", .slug = "a" ** 32 }, + .{ .input = "long " ** 40, .slug = "long-long-long-long-long-long-lo" }, + .{ .input = "🚀", .slug = "" }, + }; + try std.testing.expectEqual(@as(usize, 27), cases.len); + for (cases) |case| { + if (case.slug.len == 0) { + try std.testing.expectError(error.EmptyName, normalizeCreationName(std.testing.allocator, case.input)); + } else { + const slug = try normalizeCreationName(std.testing.allocator, case.input); + defer std.testing.allocator.free(slug); + try std.testing.expectEqualStrings(case.slug, slug); + } + } +} + +test "workspace creation naming parity detects transliteration truncation and file collisions" { + const allocator = std.testing.allocator; + var temporary = std.testing.tmpDir(.{}); + defer temporary.cleanup(); + const home = try temporary.dir.realpathAlloc(allocator, "."); + defer allocator.free(home); + try temporary.dir.makeDir(".graphcode-cafe-zurich"); + try temporary.dir.makeDir(".graphcode-" ++ "a" ** 31 ++ "-"); + try temporary.dir.writeFile(.{ .sub_path = ".graphcode-moskva", .data = "untouched" }); + for ([_][]const u8{ "Café Zürich", "CAFÉ ZÜRICH", "a" ** 31 ++ " bb", "Москва" }) |input| { + if (create(allocator, input, home)) |workspace| { + var unexpected = workspace; + unexpected.deinit(allocator); + return error.ExpectedNameCollision; + } else |err| try std.testing.expectEqual(error.NameTaken, err); + } + const untouched = try temporary.dir.readFileAlloc(allocator, ".graphcode-moskva", 100); + defer allocator.free(untouched); + try std.testing.expectEqualStrings("untouched", untouched); +} + +test "workspace creation naming parity retains Windows path safety and rejects malformed UTF8" { + const allocator = std.testing.allocator; + for ([_][]const u8{ "\xff", "\xc3", "\xed\xa0\x80" }) |input| + try std.testing.expectError(error.InvalidName, normalizeCreationName(allocator, input)); + for ([_][]const u8{ "", "relative", "C:relative", "\\relative", "C:\\bad\x00home", "C:\\\xff" }) |home| + try std.testing.expectError(error.InvalidWorkspacePath, validateCreationName(allocator, "Valid", home)); + var temporary = std.testing.tmpDir(.{}); + defer temporary.cleanup(); + const home = try temporary.dir.realpathAlloc(allocator, "."); + defer allocator.free(home); + for ([_][]const u8{ "CON", "PRN", "AUX", "NUL", "COM1", "LPT1", "../outside", "valid\x00name" }) |input| { + var workspace = try create(allocator, input, home); + defer workspace.deinit(allocator); + try std.testing.expect(std.mem.startsWith(u8, workspace.path, home)); + try std.testing.expect(std.mem.startsWith(u8, std.fs.path.basenameWindows(workspace.path), directory_prefix)); + try std.testing.expect(std.mem.indexOfScalar(u8, workspace.name, 0) == null); + } +} + +test "workspace creation naming parity owns transform expansion and all allocation failures" { + const Probe = struct { + fn run(allocator: std.mem.Allocator) !void { + const name = try normalizeCreationName(allocator, "東京 Café " ** 40); + defer allocator.free(name); + try std.testing.expectEqual(@as(usize, 32), name.len); + try std.testing.expect(std.mem.startsWith(u8, name, "dong-jing-cafe-")); + } + }; + try std.testing.checkAllAllocationFailures(std.testing.allocator, Probe.run, .{}); +} + +test "workspace creation naming parity closes ICU failure handles and retains actual status" { + const Probe = struct { + var fail_open: bool = true; + var closed: usize = 0; + var break_closed: usize = 0; + + fn open(_: [*]const u16, _: i32, _: c_int, _: ?[*]const u16, _: i32, _: ?*anyopaque, status: *i32) callconv(.c) ?*anyopaque { + status.* = if (fail_open) 16 else 0; + return @ptrFromInt(1); + } + fn transform(_: *const anyopaque, _: [*]u16, _: *i32, _: i32, _: i32, _: *i32, status: *i32) callconv(.c) void { + status.* = 7; + } + fn close(handle: *anyopaque) callconv(.c) void { + std.debug.assert(@intFromPtr(handle) == 1); + closed += 1; + } + fn breakOpen(_: c_int, _: [*:0]const u8, _: [*]const u16, _: i32, status: *i32) callconv(.c) ?*anyopaque { + status.* = 2; + return @ptrFromInt(2); + } + fn breakNext(_: *anyopaque) callconv(.c) i32 { + unreachable; + } + fn breakClose(handle: *anyopaque) callconv(.c) void { + std.debug.assert(@intFromPtr(handle) == 2); + break_closed += 1; + } + }; + Probe.closed = 0; + Probe.break_closed = 0; + Probe.fail_open = true; + var icu = NamingIcu{ + .library = undefined, + .open = &Probe.open, + .transform = &Probe.transform, + .close = &Probe.close, + .break_open = &Probe.breakOpen, + .break_next = &Probe.breakNext, + .break_close = &Probe.breakClose, + }; + try std.testing.expectError(error.WorkspaceNamingTransformFailed, icu.latin(std.testing.allocator, &.{'a'})); + try std.testing.expectEqual(@as(usize, 1), Probe.closed); + try std.testing.expectEqual(@as(i32, 16), icu.last_status); + Probe.fail_open = false; + try std.testing.expectError(error.WorkspaceNamingTransformFailed, icu.latin(std.testing.allocator, &.{'a'})); + try std.testing.expectEqual(@as(usize, 2), Probe.closed); + try std.testing.expectEqual(@as(i32, 7), icu.last_status); + try std.testing.expectError(error.WorkspaceNamingCharactersFailed, icu.characters(&.{'a'})); + try std.testing.expectEqual(@as(usize, 1), Probe.break_closed); + try std.testing.expectEqual(@as(i32, 2), icu.last_status); +} + +test "workspace creation naming parity preserves existing raw directory identities" { + const allocator = std.testing.allocator; + var temporary = std.testing.tmpDir(.{}); + defer temporary.cleanup(); + const legacy_name = "Legacy-" ++ "x" ** 48; + for ([_][]const u8{ "caf-z-rich", "Café Zürich", legacy_name }) |name| { + const directory_name = try std.fmt.allocPrint(allocator, "{s}{s}", .{ directory_prefix, name }); + defer allocator.free(directory_name); + try temporary.dir.makeDir(directory_name); + } + const home = try temporary.dir.realpathAlloc(allocator, "."); + defer allocator.free(home); + var before = try listFromHome(allocator, home); + defer before.deinit(allocator); + var created = try create(allocator, "Café Zürich", home); + defer created.deinit(allocator); + try std.testing.expectEqualStrings("cafe-zurich", created.name); + var after = try managerListFromHome(allocator, home, before.items[0].path); + defer after.deinit(allocator); + try std.testing.expectEqual(@as(usize, 4), before.items.len); + try std.testing.expectEqual(@as(usize, 5), after.items.len); + for (before.items) |original| { + for (after.items) |workspace| { + if (!std.mem.eql(u8, original.identity, workspace.identity)) continue; + try std.testing.expectEqualStrings(original.name, workspace.name); + try std.testing.expectEqualStrings(original.path, workspace.path); + break; + } else return error.ExistingWorkspaceIdentityLost; + } +} + test "workspace validation rejects long names" { var long_name: [max_name_length + 2]u8 = undefined; @memset(&long_name, 'x'); From 040ba3252b754311de48376aa6772e8b5fec10d5 Mon Sep 17 00:00:00 2001 From: Colin Neilens Date: Sun, 8 Nov 2026 16:54:24 -0700 Subject: [PATCH 2/2] Match new workspace preview and validation whitespace Reuse the verified Foundation whitespace helper, validate raw nonempty drafts, and share owned naming errors across preview and creation races while retaining legacy workspace identities. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Signed-off-by: Colin Neilens --- graphcode-windows/README.md | 19 +- graphcode-windows/src/App.zig | 22 ++- graphcode-windows/src/NewWorkspaceForm.zig | 180 +++++++++++++++++-- graphcode-windows/src/WorkspaceLifecycle.zig | 41 +++++ 4 files changed, 241 insertions(+), 21 deletions(-) diff --git a/graphcode-windows/README.md b/graphcode-windows/README.md index 883ea43e3..e57342972 100644 --- a/graphcode-windows/README.md +++ b/graphcode-windows/README.md @@ -587,8 +587,12 @@ the new support directory rather than reusing the parent's explicit override. The parent's environment and other inherited variables remain unchanged. The native New Workspace sheet shows the resolved directory, a separate-window diagram titled with the typed name, and workspace-isolation details. Invalid or -colliding names stay inline with Create & Open disabled; an empty draft stays -quiet, and Cancel discards it. Ctrl+A selects the complete focused name field +colliding names stay inline with Create & Open disabled. Only a literal empty +draft stays quiet; a nonempty whitespace-only draft reports +"Give the workspace a name — letters and numbers." Cancel discards the draft. +The diagram trims Foundation `.whitespaces`, including zero-width space, but +not `.newlines`; trimming affects only the preview, never the raw name passed +to validation or creation. Ctrl+A selects the complete focused name field without changing ordinary typing or AltGr handling. Creation and launch still use the existing workspace lifecycle and identity guards. @@ -599,6 +603,10 @@ can end in a hyphen. Preview, submit-time validation, and creation share this creation-only rule, including file/directory collisions after transliteration or truncation. Windows absolute-path validation and filesystem errors still apply; the Darwin socket-path limit does not apply to Windows named pipes. +Inline collision errors and the submit-to-create race status identify the actual +creation slug: "A workspace named cafe-zurich already exists." These messages +own their storage and propagate allocation failures; operational and +Windows-specific path errors keep their existing failure routes. The native implementation uses Windows' built-in ICU C API, loaded only from System32 (the combined DLL is available since Windows 10 1903, before the Windows 11 baseline and Server 2022/2025 CI hosts). Missing ICU, missing exports, @@ -607,6 +615,13 @@ The 27 checked naming fixtures were produced by compiling the actual shared `Workspace.slug` method with pinned Swift/Foundation 6.3.3. They establish finite fixture equivalence, not equality for every Unicode character across OS/ICU versions, nor a live native input or paired macOS visual qualification. +The actual macOS preview method and shared naming-error enum were also compiled +with that pinned Foundation: 12 preview/validation examples and an exhaustive +19-scalar `.whitespaces` set agree with the existing `SketchPromotion.trim` +helper used by this sheet. Headless tests separately retain newlines, refuse +invalid drafts without creating directories, and cover owned-message and preview +lifetimes under allocation failure. This does not establish live input, glyph +rendering, or native dialog qualification. Existing directories retain their original names, paths, identities, and discovery/activation behavior; nothing is migrated or renamed. Explicit Rename keeps its existing ASCII normalization and 48-character limit. diff --git a/graphcode-windows/src/App.zig b/graphcode-windows/src/App.zig index 57493cdf6..201b7bb5a 100644 --- a/graphcode-windows/src/App.zig +++ b/graphcode-windows/src/App.zig @@ -10189,12 +10189,22 @@ pub const App = struct { defer result.deinit(self.allocator); if (!self.ensureWorkspaceIdentity()) return; var workspace = WorkspaceLifecycle.create(self.allocator, result.name, home) catch |err| { - self.setStatus(switch (err) { - error.EmptyName => "Workspace name is required", - error.NameTooLong => "Workspace name is too long", - error.NameTaken, error.PathAlreadyExists => "That workspace already exists", - else => "Workspace could not be created", - }); + const problem: ?WorkspaceLifecycle.NameError = switch (err) { + error.EmptyName => error.EmptyName, + error.InvalidName => error.InvalidName, + error.NameTooLong => error.NameTooLong, + error.NameTaken, error.PathAlreadyExists => error.NameTaken, + else => null, + }; + if (problem) |name_problem| { + const message = WorkspaceLifecycle.nameProblemMessage(self.allocator, name_problem, result.name) catch |message_error| { + std.log.err("Workspace creation failed ({s}); naming error message failed: {s}", .{ @errorName(err), @errorName(message_error) }); + self.setStatus("Workspace name error could not be displayed"); + return; + }; + defer self.allocator.free(message); + self.setStatus(message); + } else self.setStatus("Workspace could not be created"); return; }; defer workspace.deinit(self.allocator); diff --git a/graphcode-windows/src/NewWorkspaceForm.zig b/graphcode-windows/src/NewWorkspaceForm.zig index e6b3a18ee..38464753d 100644 --- a/graphcode-windows/src/NewWorkspaceForm.zig +++ b/graphcode-windows/src/NewWorkspaceForm.zig @@ -1,5 +1,6 @@ const std = @import("std"); const Lifecycle = @import("WorkspaceLifecycle.zig"); +const SketchPromotion = @import("SketchPromotion.zig"); const Sheet = @import("SheetFramework.zig"); const Dpi = @import("Dpi.zig"); const Win32 = @import("Win32.zig"); @@ -25,37 +26,44 @@ pub const State = struct { validator: Validator = &Lifecycle.validateCreationName, draft: []u8 = &.{}, path: []u8 = &.{}, - problem: ?[]const u8 = null, + problem: ?[]u8 = null, + preview: []const u8 = "Client Work", pub fn deinit(self: *State) void { self.allocator.free(self.draft); self.allocator.free(self.path); + if (self.problem) |problem| self.allocator.free(problem); } pub fn update(self: *State, input: []const u8) !void { const draft = try self.allocator.dupe(u8, input); errdefer self.allocator.free(draft); + const typed = try SketchPromotion.trim(draft, false); var path: []u8 = &.{}; errdefer self.allocator.free(path); - var problem: ?[]const u8 = null; - if (std.mem.trim(u8, input, " \t\r\n").len != 0) { + var problem: ?[]u8 = null; + errdefer if (problem) |message| self.allocator.free(message); + if (input.len != 0) { if (self.validator(self.allocator, input, self.home)) |normalized| { defer self.allocator.free(normalized); path = try Lifecycle.workspacePath(self.allocator, normalized, self.home); } else |err| { - problem = switch (err) { - error.EmptyName, error.InvalidName => "Use letters or numbers in the workspace name", - error.NameTooLong => "Workspace name is too long", - error.NameTaken, error.PathAlreadyExists => "That workspace already exists", + problem = try Lifecycle.nameProblemMessage(self.allocator, switch (err) { + error.EmptyName => error.EmptyName, + error.InvalidName => error.InvalidName, + error.NameTooLong => error.NameTooLong, + error.NameTaken, error.PathAlreadyExists => error.NameTaken, else => return err, - }; + }, input); } } self.allocator.free(self.draft); self.allocator.free(self.path); + if (self.problem) |message| self.allocator.free(message); self.draft = draft; self.path = path; self.problem = problem; + self.preview = if (typed.len == 0) "Client Work" else typed; } pub fn canCreate(self: *const State) bool { @@ -63,8 +71,7 @@ pub const State = struct { } pub fn previewName(self: *const State) []const u8 { - const typed = std.mem.trim(u8, self.draft, " \t\r\n"); - return if (typed.len == 0) "Client Work" else typed; + return self.preview; } pub fn submit(self: *State) !?Result { @@ -524,7 +531,7 @@ const TestValidation = struct { test "new workspace empty draft is quiet and cannot submit" { var state = TestValidation.state(); defer state.deinit(); - try state.update(" "); + try state.update(""); try std.testing.expect(state.problem == null); try std.testing.expect(!state.canCreate()); try std.testing.expectEqualStrings("Client Work", state.previewName()); @@ -533,6 +540,151 @@ test "new workspace empty draft is quiet and cannot submit" { try std.testing.expectEqual(@as(usize, 0), TestValidation.calls); } +test "new workspace naming whitespace parity matches actual Foundation preview and raw validation" { + const cases = [_]struct { input: []const u8, preview: []const u8, empty_slug: bool }{ + .{ .input = "", .preview = "Client Work", .empty_slug = true }, + .{ .input = " ", .preview = "Client Work", .empty_slug = true }, + .{ .input = "\t", .preview = "Client Work", .empty_slug = true }, + .{ .input = "\n", .preview = "\n", .empty_slug = true }, + .{ .input = "\r\n", .preview = "\r\n", .empty_slug = true }, + .{ .input = "\u{85}", .preview = "\u{85}", .empty_slug = true }, + .{ .input = "\u{200b}", .preview = "Client Work", .empty_slug = true }, + .{ .input = "\u{200b}Café\u{200b}", .preview = "Café", .empty_slug = false }, + .{ .input = "\u{a0}\u{2000}Client Work\u{3000}\t", .preview = "Client Work", .empty_slug = false }, + .{ .input = "\u{2028}Café\u{2029}", .preview = "\u{2028}Café\u{2029}", .empty_slug = false }, + .{ .input = "\u{202f}東京\u{205f}", .preview = "東京", .empty_slug = false }, + .{ .input = "\u{180e}Café\u{feff}", .preview = "\u{180e}Café\u{feff}", .empty_slug = false }, + }; + try std.testing.expectEqual(@as(usize, 12), cases.len); + for (cases) |case| { + var state = TestValidation.state(); + defer state.deinit(); + try state.update(case.input); + try std.testing.expectEqualStrings(case.input, state.draft); + try std.testing.expectEqualStrings(case.preview, state.previewName()); + if (case.empty_slug) { + try std.testing.expect(!state.canCreate()); + try std.testing.expectEqualStrings("", state.path); + if (case.input.len == 0) { + try std.testing.expect(state.problem == null); + } else { + try std.testing.expect(state.problem != null); + try std.testing.expectEqualStrings("Give the workspace a name — letters and numbers.", state.problem.?); + } + try std.testing.expect((try state.submit()) == null); + } else { + try std.testing.expect(state.canCreate()); + try std.testing.expect(state.problem == null); + var result = (try state.submit()).?; + defer result.deinit(std.testing.allocator); + try std.testing.expectEqualStrings(case.input, result.name); + } + try std.testing.expectEqual(@as(usize, if (case.input.len == 0) 0 else 2), TestValidation.calls); + } +} + +test "new workspace naming whitespace parity trims all19Foundation whitespace scalars but not newlines" { + const spaces = [_]u21{ 0x09, 0x20, 0xa0, 0x1680, 0x2000, 0x2001, 0x2002, 0x2003, 0x2004, 0x2005, 0x2006, 0x2007, 0x2008, 0x2009, 0x200a, 0x200b, 0x202f, 0x205f, 0x3000 }; + try std.testing.expectEqual(@as(usize, 19), spaces.len); + for (spaces) |scalar| { + var encoded: [4]u8 = undefined; + const count = try std.unicode.utf8Encode(scalar, &encoded); + var buffer: [64]u8 = undefined; + const input = try std.fmt.bufPrint(&buffer, "{s}Client Work{s}", .{ encoded[0..count], encoded[0..count] }); + var state = TestValidation.state(); + defer state.deinit(); + try state.update(input); + try std.testing.expectEqualStrings("Client Work", state.previewName()); + try std.testing.expect(state.canCreate()); + } + for ([_]u21{ 0x0a, 0x0b, 0x0c, 0x0d, 0x85, 0x2028, 0x2029, 0x180e, 0xfeff }) |scalar| { + var encoded: [4]u8 = undefined; + const count = try std.unicode.utf8Encode(scalar, &encoded); + var buffer: [64]u8 = undefined; + const input = try std.fmt.bufPrint(&buffer, "{s}Client Work{s}", .{ encoded[0..count], encoded[0..count] }); + var state = TestValidation.state(); + defer state.deinit(); + try state.update(input); + try std.testing.expectEqualStrings(input, state.previewName()); + try std.testing.expect(state.canCreate()); + } +} + +test "new workspace naming whitespace parity reports submit create races with actual normalized slug" { + const allocator = std.testing.allocator; + var temporary = std.testing.tmpDir(.{}); + defer temporary.cleanup(); + const home = try temporary.dir.realpathAlloc(allocator, "."); + defer allocator.free(home); + var state = State{ .allocator = allocator, .home = home, .current_name = "Default" }; + defer state.deinit(); + try state.update("Café Zürich"); + var result = (try state.submit()).?; + defer result.deinit(allocator); + var other = try Lifecycle.create(allocator, "CAFE ZURICH", home); + defer other.deinit(allocator); + try std.testing.expectError(error.NameTaken, Lifecycle.create(allocator, result.name, home)); + try state.update(result.name); + try std.testing.expectEqualStrings("A workspace named cafe-zurich already exists.", state.problem.?); + const race_message = try Lifecycle.nameProblemMessage(allocator, error.NameTaken, result.name); + defer allocator.free(race_message); + try std.testing.expectEqualStrings(state.problem.?, race_message); + try std.testing.expect(!state.canCreate()); + try std.testing.expectEqualStrings("", state.path); + try std.testing.expect((try state.submit()) == null); +} + +test "new workspace naming whitespace parity owns preview problem and collision lifetimes on allocation failure" { + const Probe = struct { + fn run(allocator: std.mem.Allocator) !void { + TestValidation.collision = false; + TestValidation.failure = false; + var state = State{ .allocator = allocator, .home = "C:\\OwnedHome", .current_name = "Default", .validator = &TestValidation.validate }; + defer state.deinit(); + try state.update("\u{200b}Client Work\u{200b}"); + try std.testing.expectEqualStrings("Client Work", state.previewName()); + try state.update(" \t"); + try std.testing.expect(state.problem != null); + TestValidation.collision = true; + try state.update("東京"); + try std.testing.expectEqualStrings("A workspace named dong-jing already exists.", state.problem.?); + try state.update(""); + try std.testing.expect(state.problem == null); + try std.testing.expectEqualStrings("Client Work", state.previewName()); + try std.testing.expect(!state.canCreate()); + } + }; + defer TestValidation.collision = false; + try std.testing.checkAllAllocationFailures(std.testing.allocator, Probe.run, .{}); +} + +test "new workspace naming whitespace parity invalid drafts never create directories or rewrite valid state" { + const allocator = std.testing.allocator; + var temporary = std.testing.tmpDir(.{}); + defer temporary.cleanup(); + const home = try temporary.dir.realpathAlloc(allocator, "."); + defer allocator.free(home); + var state = State{ .allocator = allocator, .home = home, .current_name = "Default" }; + defer state.deinit(); + for ([_][]const u8{ "", " \t", "\r\n", "\u{200b}", "///" }) |input| { + try state.update(input); + try std.testing.expect((try state.submit()) == null); + try std.testing.expect(!state.canCreate()); + var directory = try std.fs.openDirAbsolute(home, .{ .iterate = true }); + defer directory.close(); + var iterator = directory.iterate(); + try std.testing.expect((try iterator.next()) == null); + } + try state.update("Valid"); + const path = try allocator.dupe(u8, state.path); + defer allocator.free(path); + try std.testing.expectError(error.InvalidUtf8, state.update("\xff")); + try std.testing.expectEqualStrings("Valid", state.draft); + try std.testing.expectEqualStrings("Valid", state.previewName()); + try std.testing.expectEqualStrings(path, state.path); + try std.testing.expect(state.canCreate()); +} + test "new workspace typed title and resolved directory remain distinct" { var state = TestValidation.state(); defer state.deinit(); @@ -565,7 +717,7 @@ test "new workspace collision at submit stays inline without a creation result" TestValidation.collision = true; try std.testing.expect((try state.submit()) == null); try std.testing.expect(state.problem != null); - try std.testing.expectEqualStrings("That workspace already exists", state.problem.?); + try std.testing.expectEqualStrings("A workspace named client-work already exists.", state.problem.?); try std.testing.expect(!state.canCreate()); TestValidation.collision = false; try state.update("Client Work 2"); @@ -656,7 +808,9 @@ test "new workspace production validator preview submit and create agree on Unic defer workspace.deinit(allocator); try std.testing.expectEqualStrings(workspace.path, state.path); try std.testing.expect((try state.submit()) == null); - try std.testing.expectEqualStrings("That workspace already exists", state.problem.?); + const expected = try std.fmt.allocPrint(allocator, "A workspace named {s} already exists.", .{workspace.name}); + defer allocator.free(expected); + try std.testing.expectEqualStrings(expected, state.problem.?); try std.testing.expectEqualStrings("", state.path); } } diff --git a/graphcode-windows/src/WorkspaceLifecycle.zig b/graphcode-windows/src/WorkspaceLifecycle.zig index b983acbc1..541f11b0e 100644 --- a/graphcode-windows/src/WorkspaceLifecycle.zig +++ b/graphcode-windows/src/WorkspaceLifecycle.zig @@ -132,6 +132,19 @@ pub const NameError = error{ NameTaken, }; +pub fn nameProblemMessage(allocator: std.mem.Allocator, problem: NameError, input: []const u8) ![]u8 { + return switch (problem) { + error.EmptyName => allocator.dupe(u8, "Give the workspace a name — letters and numbers."), + error.InvalidName => allocator.dupe(u8, "Use letters or numbers in the workspace name"), + error.NameTooLong => allocator.dupe(u8, "Workspace name is too long"), + error.NameTaken => message: { + const slug = try normalizeCreationName(allocator, input); + defer allocator.free(slug); + break :message std.fmt.allocPrint(allocator, "A workspace named {s} already exists.", .{slug}); + }, + }; +} + pub fn defaultPath(allocator: std.mem.Allocator) ![]u8 { const home = std.process.getEnvVarOwned(allocator, "USERPROFILE") catch |err| switch (err) { error.EnvironmentVariableNotFound => return error.UserProfileMissing, @@ -642,6 +655,34 @@ test "workspace creation naming parity closes ICU failure handles and retains ac try std.testing.expectEqual(@as(i32, 2), icu.last_status); } +test "workspace creation naming messages use macOS empty and actual creation collision slugs" { + const cases = [_]struct { problem: NameError, input: []const u8, message: []const u8 }{ + .{ .problem = error.EmptyName, .input = " \t", .message = "Give the workspace a name — letters and numbers." }, + .{ .problem = error.NameTaken, .input = "Café Zürich", .message = "A workspace named cafe-zurich already exists." }, + .{ .problem = error.NameTaken, .input = "a" ** 31 ++ " bb", .message = "A workspace named " ++ "a" ** 31 ++ "- already exists." }, + .{ .problem = error.InvalidName, .input = "\xff", .message = "Use letters or numbers in the workspace name" }, + .{ .problem = error.NameTooLong, .input = "long", .message = "Workspace name is too long" }, + }; + for (cases) |case| { + const message = try nameProblemMessage(std.testing.allocator, case.problem, case.input); + defer std.testing.allocator.free(message); + try std.testing.expectEqualStrings(case.message, message); + } +} + +test "workspace creation naming messages release every allocation and propagate failures" { + const Probe = struct { + fn run(allocator: std.mem.Allocator) !void { + for ([_]NameError{ error.EmptyName, error.InvalidName, error.NameTooLong, error.NameTaken }) |problem| { + const message = try nameProblemMessage(allocator, problem, "東京 Café"); + defer allocator.free(message); + try std.testing.expect(message.len != 0); + } + } + }; + try std.testing.checkAllAllocationFailures(std.testing.allocator, Probe.run, .{}); +} + test "workspace creation naming parity preserves existing raw directory identities" { const allocator = std.testing.allocator; var temporary = std.testing.tmpDir(.{});