Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions Tools/windows/Tests/WindowsShell.Tests.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -696,6 +696,16 @@ Invoke-Native "Workspace manager data executable tests" {
Push-Location $shellRoot
try { & $zig test src\WorkspaceManager.zig } finally { Pop-Location }
}
Invoke-Native "Workspace teardown executable tests" {
$depotRoot = Split-Path (Split-Path $repoRoot -Parent) -Parent
$winghosttyRoot = [Environment]::GetEnvironmentVariable("GRAPHCODE_WINGHOSTTY_ROOT")
if (-not $winghosttyRoot) { $winghosttyRoot = Join-Path $depotRoot "Winghostty-worktrees\host-integration" }
$include = Join-Path $winghosttyRoot "include"
Push-Location $shellRoot
try {
& $zig test src\WorkspaceTeardown.zig -target x86_64-windows-msvc -lc -ladvapi32 -lshell32 -lkernel32 "-I$include"
} finally { Pop-Location }
}
Invoke-Native "Workspace manager form data executable tests" {
$depotRoot = Split-Path (Split-Path $repoRoot -Parent) -Parent
$winghosttyRoot = [Environment]::GetEnvironmentVariable("GRAPHCODE_WINGHOSTTY_ROOT")
Expand Down
77 changes: 65 additions & 12 deletions graphcode-windows/src/App.zig
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ const WorkspaceControls = @import("WorkspaceControls.zig");
const WorkspaceLifecycle = @import("WorkspaceLifecycle.zig");
const WorkspaceManager = @import("WorkspaceManager.zig");
const WorkspaceManagerForm = @import("WorkspaceManagerForm.zig");
const WorkspaceTeardown = @import("WorkspaceTeardown.zig");
const Win32 = @import("Win32.zig");
const c = Win32.c;

Expand Down Expand Up @@ -203,7 +204,12 @@ fn openWorkspaceWith(comptime Api: type, allocator: std.mem.Allocator, current_i
}

const WorkspaceMutation = union(enum) { rename: []const u8, delete };
const WorkspaceMutationResult = enum { renamed, deleted, cancelled };
const WorkspaceMutationResult = union(enum) {
renamed,
cancelled,
/// Deletion ran; the report says how far it got and what, if anything, it put back.
torn_down: WorkspaceTeardown.Report,
};
const workspace_delete_confirmation_flags = c.MB_YESNO | c.MB_ICONWARNING | c.MB_DEFBUTTON2;

const WorkspaceMutationApi = struct {
Expand All @@ -213,8 +219,8 @@ const WorkspaceMutationApi = struct {
fn confirm(owner: c.HWND) c.INT {
return c.MessageBoxW(
owner,
std.unicode.utf8ToUtf16LeStringLiteral("This permanently deletes the workspace folder and all of its projects and loops. Continue?").ptr,
std.unicode.utf8ToUtf16LeStringLiteral("Delete Workspace").ptr,
std.unicode.utf8ToUtf16LeStringLiteral(WorkspaceTeardown.confirmation_text).ptr,
std.unicode.utf8ToUtf16LeStringLiteral(WorkspaceTeardown.delete_caption).ptr,
workspace_delete_confirmation_flags,
);
}
Expand All @@ -227,8 +233,12 @@ const WorkspaceMutationApi = struct {
if (c.MoveFileW(from.ptr, to.ptr) == 0) return error.WorkspaceRenameFailed;
}

fn delete(path: []const u8) !void {
try std.fs.deleteTreeAbsolute(path);
fn delete(
allocator: std.mem.Allocator,
path: []const u8,
current_identity: []const u8,
) !WorkspaceTeardown.Report {
return WorkspaceTeardown.deleteRecoverably(allocator, path, current_identity);
}
};

Expand Down Expand Up @@ -277,10 +287,15 @@ fn mutateWorkspaceWith(
.delete => {
if (Api.confirm(owner) != c.IDYES) return .cancelled;
try requireIdentifiedClosedWorkspace(Api, key);
// The confirmation pumped messages, so the target is re-derived from the path
// we captured and re-checked against Default and this window before any effect.
const confirmed = try WorkspaceLifecycle.pathIdentity(allocator, source);
defer allocator.free(confirmed);
if (!std.mem.eql(u8, confirmed, identity)) return error.WorkspaceIdentityChanged;
if (std.mem.eql(u8, confirmed, current_identity)) return error.CurrentWorkspace;
var directory = try std.fs.openDirAbsolute(source, .{});
directory.close();
try Api.delete(source);
return .deleted;
return .{ .torn_down = try Api.delete(allocator, source, current_identity) };
},
}
}
Expand Down Expand Up @@ -6287,6 +6302,13 @@ pub const App = struct {
if (!self.validateManagerAction(action)) return;
self.renameWorkspaceTo(target, result.values[0]);
},
.delete => {
const target = action.target.?;
// Revalidated once more here: the manager's own modal has closed since the
// action was captured.
if (!self.validateManagerAction(action)) return;
self.deleteWorkspaceTarget(target);
},
}
}

Expand Down Expand Up @@ -6331,12 +6353,31 @@ pub const App = struct {
self.setStatus("Workspace was not found");
return;
};
self.deleteWorkspaceTarget(workspace);
}

/// Shared by the menu path and the manager's Delete button so both get the same
/// refusals, the same confirmation, and the same honest status.
fn deleteWorkspaceTarget(self: *App, workspace: WorkspaceLifecycle.Workspace) void {
const outcome = mutateWorkspaceWith(WorkspaceMutationApi, self.allocator, self.window.hwnd, self.workspace_identity, workspace, .delete) catch |err| {
self.setStatus(workspaceMutationFailure(err));
return;
};
if (!self.refreshWorkspaceList()) return;
self.setStatus(if (outcome == .deleted) "Workspace deleted" else "Workspace deletion cancelled");
switch (outcome) {
.renamed => {},
.cancelled => self.setStatus("Workspace deletion cancelled"),
.torn_down => |value| {
var report = value;
defer report.deinit(self.allocator);
if (!self.refreshWorkspaceList()) return;
const message = WorkspaceTeardown.statusMessage(self.allocator, report) catch {
self.setStatus("Workspace deletion finished but its result could not be described");
return;
};
defer self.allocator.free(message);
self.setStatus(message);
},
}
}

fn cycleWorkspace(self: *App, direction: isize) void {
Expand Down Expand Up @@ -8254,10 +8295,20 @@ const WorkspaceMutationFixture = struct {
}
return response;
}
fn delete(path: []const u8) !void {
/// Deletion is simulated here: App owns the refusals and the confirmation, while the
/// recoverable teardown itself is proven in WorkspaceTeardown against its own seam. No
/// test ever recycles a folder, signals a daemon, or kills a session.
fn delete(
allocator: std.mem.Allocator,
path: []const u8,
current_identity: []const u8,
) !WorkspaceTeardown.Report {
_ = allocator;
_ = current_identity;
deletions += 1;
try std.testing.expectEqualStrings(expected_path, path);
if (!skip_delete) try WorkspaceMutationApi.delete(path);
if (!skip_delete) try std.fs.deleteTreeAbsolute(path);
return .{ .outcome = .deleted, .sessions_targeted = 0, .sessions_known = true };
}
};

Expand Down Expand Up @@ -8323,7 +8374,9 @@ test "workspace deletion guards default current open legacy and every non Yes re
WorkspaceMutationFixture.reset(saved_alpha_path);
WorkspaceMutationFixture.response = c.IDYES;
WorkspaceMutationFixture.list_to_release = &list;
try std.testing.expectEqual(WorkspaceMutationResult.deleted, try mutateWorkspaceWith(WorkspaceMutationFixture, allocator, null, default.identity, alpha, .delete));
var torn_down = try mutateWorkspaceWith(WorkspaceMutationFixture, allocator, null, default.identity, alpha, .delete);
defer torn_down.torn_down.deinit(allocator);
try std.testing.expectEqual(WorkspaceTeardown.Outcome.deleted, torn_down.torn_down.outcome);
try std.testing.expect(WorkspaceMutationFixture.held_during_confirmation);
try std.testing.expectEqual(@as(usize, 1), WorkspaceMutationFixture.deletions);
try requireDeletedWorkspace(saved_alpha_path);
Expand Down
36 changes: 23 additions & 13 deletions graphcode-windows/src/DaemonClient.zig
Original file line number Diff line number Diff line change
Expand Up @@ -341,19 +341,7 @@ pub const DaemonClient = struct {

pub fn currentDaemonLockName(self: *DaemonClient, allocator: std.mem.Allocator) ![]u8 {
_ = self;
const support = try supportDirectory(allocator);
defer allocator.free(support);
const normalized = try normalizedSupportPath(allocator, support);
defer allocator.free(normalized);
const support_hash = try sha256Hex(allocator, normalized);
defer allocator.free(support_hash);
const sid = try currentSID(allocator);
defer allocator.free(sid);
return std.fmt.allocPrint(
allocator,
"Global\\graphcode-daemon-{s}-{s}",
.{ sid, support_hash[0..20] },
);
return daemonLockName(allocator);
}

fn validateSupportDirectory(allocator: std.mem.Allocator, support_directory: []const u8) !void {
Expand Down Expand Up @@ -1709,6 +1697,28 @@ fn currentSID(allocator: std.mem.Allocator) ![]u8 {
return wideToUtf8(allocator, sid_text);
}

/// Lock name for the daemon that owns `support_directory`. Derived exactly as the Swift
/// daemon derives it, so this addresses another workspace's daemon, not only our own.
pub fn daemonLockNameFor(allocator: std.mem.Allocator, support_directory: []const u8) ![]u8 {
const normalized = try normalizedSupportPath(allocator, support_directory);
defer allocator.free(normalized);
const support_hash = try sha256Hex(allocator, normalized);
defer allocator.free(support_hash);
const sid = try currentSID(allocator);
defer allocator.free(sid);
return std.fmt.allocPrint(
allocator,
"Global\\graphcode-daemon-{s}-{s}",
.{ sid, support_hash[0..20] },
);
}

pub fn daemonLockName(allocator: std.mem.Allocator) ![]u8 {
const support = try supportDirectory(allocator);
defer allocator.free(support);
return daemonLockNameFor(allocator, support);
}

fn sha256Hex(allocator: std.mem.Allocator, bytes: []const u8) ![]u8 {
var digest: [32]u8 = undefined;
std.crypto.hash.sha2.Sha256.hash(bytes, &digest, .{});
Expand Down
62 changes: 54 additions & 8 deletions graphcode-windows/src/WorkspaceManager.zig
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ pub const Summary = union(enum) {
saved: Counts,
failed: SummaryFailure,
};
pub const delete_reason = "Delete is unavailable here until recoverable deletion with daemon/session teardown is implemented.";
pub const delete_note = "Delete stops that workspace's daemon, ends its saved terminal sessions, and moves its folder to the Recycle Bin, where Windows can restore it.";

pub const Row = struct {
workspace: Lifecycle.Workspace,
Expand All @@ -36,7 +36,7 @@ pub const Row = struct {
}
};

pub const ActionKind = enum { open, rename, new };
pub const ActionKind = enum { open, rename, new, delete };
pub const Action = struct {
kind: ActionKind,
target: ?Lifecycle.Workspace = null,
Expand Down Expand Up @@ -79,13 +79,15 @@ pub const Model = struct {
const index = selection orelse return error.NoWorkspaceSelected;
if (index >= self.rows.len) return error.NoWorkspaceSelected;
const row = self.rows[index];
if (kind == .rename) try requireRename(row.refusal());
if (kind == .rename or kind == .delete) try requireExclusiveTarget(row.refusal());
if (kind == .open and !row.canOpen()) return error.UnidentifiedWorkspaceWindow;
return .{ .kind = kind, .target = try Lifecycle.copyWorkspace(self.allocator, row.workspace) };
}
};

fn requireRename(refusal: Refusal) !void {
/// Renaming and deleting both need a row nothing else holds: not Default, not this window's,
/// not open elsewhere, and not a window we failed to identify.
fn requireExclusiveTarget(refusal: Refusal) !void {
return switch (refusal) {
.none => {},
.default => error.DefaultWorkspace,
Expand All @@ -106,7 +108,7 @@ pub fn validateTarget(allocator: std.mem.Allocator, action: Action, current_iden
if (!std.mem.eql(u8, identity, target.identity)) return error.WorkspaceIdentityChanged;
const is_default = std.mem.eql(u8, identity, default_identity);
if (is_default != target.is_default) return error.WorkspaceIdentityChanged;
if (action.kind == .rename) {
if (action.kind == .rename or action.kind == .delete) {
if (is_default) return error.DefaultWorkspace;
if (std.mem.eql(u8, identity, current_identity)) return error.CurrentWorkspace;
}
Expand All @@ -115,9 +117,9 @@ pub fn validateTarget(allocator: std.mem.Allocator, action: Action, current_iden
pub fn refusalText(refusal: Refusal) []const u8 {
return switch (refusal) {
.none => "Closed",
.default => "Default workspace - cannot rename",
.current => "This window - cannot rename",
.open => "Open elsewhere - cannot rename",
.default => "Default workspace - cannot rename or delete",
.current => "This window - cannot rename or delete",
.open => "Open elsewhere - cannot rename or delete",
.unidentified => "Older/unidentified window - close it before changing workspaces",
.unavailable => "Window ownership unavailable - actions blocked",
};
Expand Down Expand Up @@ -646,6 +648,50 @@ test "workspace manager unknown error and refusal states never become zero count
try std.testing.expectEqualStrings("Saved: 0 projects / 0 top-level loops", zero);
}

test "workspace manager Delete refuses every row rename refuses and keeps its own target" {
const allocator = std.testing.allocator;
var def = alpha;
def.is_default = true;
var model = try Model.init(allocator, &.{ def, beta, alpha, beta }, "c:/elsewhere", &.{ .closed, .open, .unidentified, .unavailable });
defer model.deinit();
for (0..model.rows.len) |i| {
try std.testing.expectError(
if (i == 0) error.DefaultWorkspace else if (i == 1) error.WorkspaceInUse else error.UnidentifiedWorkspaceWindow,
model.capture(.delete, i),
);
}
try std.testing.expectError(error.NoWorkspaceSelected, model.capture(.delete, null));
try std.testing.expectError(error.NoWorkspaceSelected, model.capture(.delete, model.rows.len));

var closed = try Model.init(allocator, &.{alpha}, "c:/elsewhere", &.{.closed});
defer closed.deinit();
var action = try closed.capture(.delete, 0);
defer action.deinit(allocator);
try std.testing.expectEqual(ActionKind.delete, action.kind);
try std.testing.expectEqualStrings(alpha.identity, action.target.?.identity);
}

test "workspace manager Delete revalidates identity default and current after confirmation" {
const allocator = std.testing.allocator;
const action = Action{ .kind = .delete, .target = alpha };
try std.testing.expectError(error.CurrentWorkspace, validateTarget(allocator, action, alpha.identity, "c:/fixture/.graphcode"));
try std.testing.expectError(error.WorkspaceIdentityChanged, validateTarget(allocator, action, "c:/elsewhere", alpha.identity));
var default_target = Action{ .kind = .delete, .target = alpha };
default_target.target.?.is_default = true;
try std.testing.expectError(error.DefaultWorkspace, validateTarget(allocator, default_target, "c:/elsewhere", alpha.identity));
var drifted = action;
drifted.target.?.path = beta.path;
try std.testing.expectError(error.WorkspaceIdentityChanged, validateTarget(allocator, drifted, "c:/elsewhere", "c:/fixture/.graphcode"));
try validateTarget(allocator, action, "c:/elsewhere", "c:/fixture/.graphcode");
}

test "workspace manager refusal text covers deletion as well as renaming" {
for ([_]Refusal{ .default, .current, .open }) |refusal| {
const text = refusalText(refusal);
try std.testing.expect(std.mem.indexOf(u8, text, "rename or delete") != null);
}
}

const graph_fixture =
\\{"id":"11111111-1111-1111-1111-111111111111","project":{"path":"C:\\project","name":"Project","lastOpenedAt":0},"nodes":[{"id":"22222222-2222-2222-2222-222222222222","title":"Loop","loopType":"turnBased","subGraph":{"nodes":[{},{}]}}],"edges":[]}
;
Expand Down
Loading
Loading