Revert Close Approval
This commit is contained in:
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
@@ -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
@@ -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)
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user