Revert Close Approval

This commit is contained in:
Kaede Akino committed 2026-09-18 20:00:55 +08:00
1 parent b96671383e
commit 0e85a5cec9
7 files changed
+179 -59

No files matched your search

@@ -5215,6 +5215,22 @@
}
}
},
"Close this window?" : {
"localizations" : {
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
"value" : "关闭此窗口?"
}
},
"zh-Hant" : {
"stringUnit" : {
"state" : "translated",
"value" : "關閉此視窗?"
}
}
}
},
"Close Window" : {
"localizations" : {
"zh-Hans" : {
@@ -18598,6 +18614,23 @@
}
}
},
"Quit Anyway" : {
"extractionState" : "manual",
"localizations" : {
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
"value" : "仍然退出"
}
},
"zh-Hant" : {
"stringUnit" : {
"state" : "translated",
"value" : "仍然結束"
}
}
}
},
"Quit Termind" : {
"localizations" : {
"zh-Hans" : {
@@ -18614,6 +18647,23 @@
}
}
},
"Quit with unuploaded changes?" : {
"extractionState" : "manual",
"localizations" : {
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
"value" : "仍有修改未上传,确定退出吗?"
}
},
"zh-Hant" : {
"stringUnit" : {
"state" : "translated",
"value" : "仍有修改未上傳,確定結束嗎?"
}
}
}
},
"Ran “%@”" : {
"extractionState" : "stale",
"localizations" : {
@@ -26475,6 +26525,22 @@
}
}
},
"This window still has open Workspaces. Closing it will close them and disconnect their sessions." : {
"localizations" : {
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
"value" : "此窗口中仍有打开的工作区。关闭窗口将关闭这些工作区并断开其中的会话连接。"
}
},
"zh-Hant" : {
"stringUnit" : {
"state" : "translated",
"value" : "此視窗中仍有開啟的工作區。關閉視窗將關閉這些工作區並中斷其中的工作階段連線。"
}
}
}
},
"This Workspace can no longer be saved as a template." : {
"localizations" : {
"zh-Hans" : {
@@ -29336,6 +29402,23 @@
}
}
},
"Your edited files have not all been uploaded. Pending edits will not be restored next time." : {
"extractionState" : "manual",
"localizations" : {
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
"value" : "编辑过的文件尚未全部上传。下次启动时不会恢复待上传记录。"
}
},
"zh-Hant" : {
"stringUnit" : {
"state" : "translated",
"value" : "編輯過的檔案尚未全部上傳。下次啟動時不會還原待上傳記錄。"
}
}
}
},
"Your terminal, with judgment." : {
"localizations" : {
"zh-Hans" : {
+10 -6
View File
@@ -80,10 +80,15 @@ cleanup is performed.
demos use an in-memory size store. Window positions remain system-owned.
- Desktop Command-W closes the selected Workspace's focused pane first. Closing
the last pane removes its Workspace but keeps the window open. Without a selected
Workspace it requests native window dismissal. Window closure does not ask for
confirmation, including when the window owns connected, disconnected, or hidden
Workspaces. Workspace presence does not mark the window as edited. Shift-Command-W remains
Workspace it requests native window dismissal. All user window-close paths share
SwiftUI's dismissal confirmation whenever that window owns any Workspaces,
including disconnected or hidden ones; cancellation preserves them. Closing a
different window never participates in that decision. Shift-Command-W remains
direct window dismissal, and the existing last-window background policy applies.
Main-window registration suppresses the native document-edited decoration that
SwiftUI couples to dismissal confirmation. Workspaces are not unsaved documents;
the confirmation condition and native dismissal delegate remain unchanged.
The observation follows the registration view's window and ends on detachment.
- A Workspace can move intact to a new window through commands. Navigator drags
use SwiftUI's original UUID string provider for in-window ordering and pane
composition; releasing a drag outside the window never creates a window.
@@ -307,9 +312,8 @@ be discarded; paused uploads are cancelled and cleaned up before their record is
Hosts own activation, background, termination and native preview/open adapters.
The macOS edit menu uses ordinary menu actions; Keep Local Copy delegates to an app-owned
save panel and coordinated file copy instead of embedding ShareLink in the native menu.
macOS quits without checking or confirming pending edits and retains its bounded cleanup.
Neither platform restores edit tracking after process termination or automatically uploads
pending edits on quit. Background expiration still pauses uploads, with explicit resume.
macOS checks edits before quitting; iOS cannot promise a quit warning or recovery after
process termination. Background expiration still pauses uploads, with explicit resume.
Edit upload policy validates the original destination's kind, size, modification time
and mode both before writing and immediately before remote rename, preserving its
+14 -1
View File
@@ -594,7 +594,20 @@ The operator approved R1, R2, and R3 in that order. R4–R6 remain outside this
- Action: remove both confirmation paths and their unused copy while preserving native
window dismissal, pane-first Command-W, per-window session ownership, and bounded quit cleanup.
- Estimated reduction: 25-35 production Swift lines and five localized catalog entries.
- Status: implementation in progress; verification pending.
- Status: superseded by the user's request to retain both confirmations and hide
only the close-button edited indicator. Both dialogs and all five strings are restored.
## Hide the Desktop Close-Button Edited Indicator (2026-09-18)
- Evidence: the restored SwiftUI WindowGroup sets `NSWindow.isDocumentEdited` when
a Workspace requires dismissal confirmation. An isolated Demo probe cleared that
property and still presented the original Close Window / Cancel dialog.
- Action: observe the native edited flag only while the main-window registration
view is attached and clear the decoration. Retain SwiftUI's dismissal delegate,
Workspace condition, all confirmation copy, and SFTP quit checks.
- This is a visual behavior correction, not a code-reduction refactor. No shared
API, persistence, package, or dependency change is needed.
- Status: implemented; verification pending.
## Make Workspace Composition Free (2026-09-18)
+7
View File
@@ -172,6 +172,13 @@ struct RootView: View {
} message: {
Text(window.workspaceTemplateNotice ?? "")
}
.dismissalConfirmationDialog("Close this window?", shouldPresent: !window.workspaces.isEmpty) {
Button("Cancel", role: .cancel) {}
.keyboardShortcut(.defaultAction)
Button("Close Window", role: .destructive) {}
} message: {
Text("This window still has open Workspaces. Closing it will close them and disconnect their sessions.")
}
// No ancestor drop target: pane and Navigator targets must own internal
// layout drops without competing for the same text payload.
.environment(window)
+19 -1
View File
@@ -325,7 +325,25 @@ struct TermindApp: App {
guard let app else { return .terminateNow }
if awaitingTermination { return .terminateLater }
awaitingTermination = true
beginTermination(app)
Task { @MainActor [weak self] in
await app.sftpEdits.recheck()
if app.sftpEdits.model.hasPendingChanges {
let alert = NSAlert()
alert.messageText = String(localized: "Quit with unuploaded changes?")
alert.informativeText = String(
localized:
"Your edited files have not all been uploaded. Pending edits will not be restored next time."
)
alert.addButton(withTitle: String(localized: "Cancel"))
alert.addButton(withTitle: String(localized: "Quit Anyway"))
guard alert.runModal() == .alertSecondButtonReturn else {
self?.awaitingTermination = false
NSApp.reply(toApplicationShouldTerminate: false)
return
}
}
self?.beginTermination(app)
}
return .terminateLater
}
+9 -1
View File
@@ -215,7 +215,7 @@ final class MainWindowCoordinator {
}
/// A selected pane consumes the command, even when it was the last one.
/// Without a selected Workspace, use the window's native dismissal path.
/// Window fallback uses the native dismissal path so confirmation can veto it.
func closeSelectionOrWindow(in model: WindowModel) {
guard !model.isClosed else { return }
if model.selectedWorkspace != nil {
@@ -325,6 +325,7 @@ final class MainWindowRegistrationView: NSView {
private let coordinator: MainWindowCoordinator
private let model: WindowModel
private let requestID: UUID?
private var documentEditedObservation: NSKeyValueObservation?
init(coordinator: MainWindowCoordinator, model: WindowModel, requestID: UUID?) {
self.coordinator = coordinator
@@ -338,6 +339,13 @@ final class MainWindowRegistrationView: NSView {
override func viewDidMoveToWindow() {
super.viewDidMoveToWindow()
documentEditedObservation = nil
// SwiftUI marks a window with dismissal confirmation as edited. Workspaces
// are not documents; suppress that decoration without changing dismissal.
documentEditedObservation = window?.observe(\.isDocumentEdited, options: [.initial, .new]) { window, change in
guard change.newValue == true else { return }
MainActor.assumeIsolated { window.isDocumentEdited = false }
}
register()
}
+37 -50
View File
@@ -296,10 +296,12 @@ struct MultiWindowTests {
let model = WindowModel(workspaces: [workspace])
model.selectedDestination = destination
let native = nativeWindow()
let approval = CloseVeto()
let approval = CloseApproval()
native.delegate = approval
defer { native.close() }
app.mainWindows.register(native, model: model, requestID: nil)
native.contentView = MainWindowRegistrationView(coordinator: app.mainWindows, model: model, requestID: nil)
native.isDocumentEdited = true
#expect(!native.isDocumentEdited)
app.mainWindows.closeSelectionOrWindow(in: model)
@@ -311,10 +313,8 @@ struct MultiWindowTests {
#expect(!model.canReopen)
}
@Test(arguments: [false, true], [WindowDestination.vault, .settings, .assistant, .metrics])
func windowCloseWithoutConfirmationOnlyDisconnectsTheRequestedWindowsSessions(
directClose: Bool, destination: WindowDestination
) throws {
@Test(arguments: [false, true])
func approvedWindowCloseOnlyDisconnectsTheRequestedWindowsSessions(directClose: Bool) throws {
let host = Host(name: "Server", host: "server.example")
let app = AppModel(hosts: HostLibraryModel(hosts: [host]))
let firstWorkspace = Workspace.sshSession(host: host)
@@ -325,9 +325,12 @@ struct MultiWindowTests {
secondSession.status = .connected
let firstModel = WindowModel(workspaces: [firstWorkspace])
let secondModel = WindowModel(workspaces: [secondWorkspace])
if !directClose { firstModel.selectedDestination = destination }
if !directClose { firstModel.openSettings() }
let first = nativeWindow()
let second = nativeWindow()
let approval = CloseApproval()
approval.allowsClose = true
first.delegate = approval
defer {
first.close()
second.close()
@@ -341,15 +344,37 @@ struct MultiWindowTests {
app.mainWindows.closeSelectionOrWindow(in: firstModel)
}
#expect(approval.requests == 1)
#expect(firstModel.isClosed)
#expect(firstModel.workspaces.isEmpty)
#expect(!firstModel.canReopen)
#expect(firstSession.status == .disconnected)
#expect(!secondModel.isClosed)
#expect(secondModel.selectedWorkspace === secondWorkspace)
#expect(secondSession.status == .connected)
}
@Test
func registrationSuppressesOnlyItsWindowsEditedIndicatorUntilDetached() {
let app = AppModel(hosts: HostLibraryModel())
let model = WindowModel()
let native = nativeWindow()
let auxiliary = nativeWindow()
defer {
native.close()
auxiliary.close()
}
native.isDocumentEdited = true
auxiliary.isDocumentEdited = true
native.contentView = MainWindowRegistrationView(coordinator: app.mainWindows, model: model, requestID: nil)
#expect(!native.isDocumentEdited)
native.isDocumentEdited = true
#expect(!native.isDocumentEdited)
#expect(auxiliary.isDocumentEdited)
native.contentView = nil
native.isDocumentEdited = true
#expect(native.isDocumentEdited)
}
@Test(arguments: [WindowDestination.vault, .settings, .assistant, .metrics])
func closeCommandClosesAnEmptyWindowFromAnyPinnedPage(destination: WindowDestination) {
let app = AppModel(hosts: HostLibraryModel())
@@ -379,51 +404,13 @@ struct MultiWindowTests {
#expect(!unregistered.isClosed)
}
@Test(arguments: [false, true])
func quitStartsSessionCleanupWithoutWaitingForPendingEdits(hasPendingEdits: Bool) async throws {
let host = Host(name: "Server", host: "server.example")
let app = AppModel(hosts: HostLibraryModel(hosts: [host]))
let workspace = Workspace.sshSession(host: host)
let session = try #require(workspace.focusedPane.sshSession)
session.status = .connected
let model = WindowModel(workspaces: [workspace])
app.registerWindow(model)
let edits = app.sftpEdits.model
if hasPendingEdits {
let file = RemoteFile(
path: "/srv/file", name: "file", kind: .file, size: 4,
modifiedAt: nil, permissions: 0o644, ownerUID: 0, ownerGID: 0)
let id = edits.register(
hostID: host.id, file: file,
localURL: URL(filePath: "/termind-test-unreadable-edit"), digest: "original")
edits.update(id) { $0.currentDigest = "edited" }
}
// Release this test delegate before yielding so its weak completion tasks
// cannot send a termination reply to the real test-host application.
weak var releasedDelegate: AppDelegate?
autoreleasepool {
let delegate = AppDelegate()
releasedDelegate = delegate
delegate.app = app
#expect(delegate.applicationShouldTerminate(NSApp) == .terminateLater)
#expect(model.workspaces.isEmpty)
#expect(session.status == .disconnected)
#expect(edits.hasPendingChanges == hasPendingEdits)
#expect(edits.files.allSatisfy { $0.localError == nil && $0.transferID == nil })
#expect(delegate.applicationShouldTerminate(NSApp) == .terminateLater)
}
#expect(releasedDelegate == nil)
await app.shutdownForTermination().value
}
private final class CloseVeto: NSObject, NSWindowDelegate {
private final class CloseApproval: NSObject, NSWindowDelegate {
var allowsClose = false
var requests = 0
func windowShouldClose(_ sender: NSWindow) -> Bool {
requests += 1
return false
return allowsClose
}
}