From aedae9fe9548a87da1487c29087cb5a53607d803 Mon Sep 17 00:00:00 2001 From: wzz6423 <2705704576@qq.com> Date: Thu, 17 Sep 2026 09:03:14 +0800 Subject: [PATCH] fix(workspace): finish grouping interactions and window containment Co-authored-by: Codex --- mac/zshell/AppKitProjectSidebarView.swift | 78 ++-- mac/zshell/AppKitSessionTabsView.swift | 32 +- mac/zshell/AppKitWorktreeSectionView.swift | 334 ++++++++++++++++++ mac/zshell/AppWindowPresentation.swift | 138 ++++++++ mac/zshell/BrowserView.swift | 8 +- mac/zshell/ContentView.swift | 7 +- mac/zshell/FileTreeModel.swift | 3 +- mac/zshell/Project.swift | 26 +- mac/zshell/ProjectGroup.swift | 26 +- mac/zshell/ProjectTabMarkerColor.swift | 49 ++- mac/zshell/QuickCommands.swift | 8 +- mac/zshell/QuickLaunchEditorController.swift | 20 +- mac/zshell/QuickLaunchPanelController.swift | 59 +--- mac/zshell/RightSidebarView.swift | 97 +---- mac/zshell/SSHProjectController.swift | 10 +- mac/zshell/Settings/SettingsGeneralPane.swift | 7 +- mac/zshell/SettingsImportExport.swift | 20 +- mac/zshell/SidebarLayoutMetrics.swift | 2 + mac/zshell/TerminalEnvironmentEditor.swift | 16 +- mac/zshell/TerminalManager.swift | 2 + mac/zshell/WorkspaceChromeViews.swift | 70 +++- web/content/docs/git.mdx | 4 +- web/content/docs/git.zh.mdx | 2 +- web/content/docs/projects.mdx | 16 +- web/content/docs/projects.zh.mdx | 8 +- 25 files changed, 790 insertions(+), 252 deletions(-) create mode 100644 mac/zshell/AppKitWorktreeSectionView.swift create mode 100644 mac/zshell/AppWindowPresentation.swift diff --git a/mac/zshell/AppKitProjectSidebarView.swift b/mac/zshell/AppKitProjectSidebarView.swift index bb8c6f4..523a355 100644 --- a/mac/zshell/AppKitProjectSidebarView.swift +++ b/mac/zshell/AppKitProjectSidebarView.swift @@ -50,7 +50,7 @@ private final class ProjectSidebarOutlineView: NSView { } final class ProjectSidebarNSView: NSView { - private enum Item: Hashable { case ungrouped, project(UUID), group(UUID) } + private enum Item: Hashable { case project(UUID), group(UUID) } private let manager: TerminalManager private let tabDrag: TabSplitDragCoordinator private let groupStore = ProjectGroupStore.shared @@ -72,6 +72,7 @@ final class ProjectSidebarNSView: NSView { private var revealSelection = true private var draggedItem: Item? private var dropItem: Item? + private var isUngroupedDropTarget = false private var isFolderDropTarget = false { didSet { outline.dropFrame = isFolderDropTarget ? scrollView.frame : nil; outline.needsDisplay = true } } @@ -145,8 +146,7 @@ final class ProjectSidebarNSView: NSView { func refresh() { let groupIDs = Set(groupStore.groups.map(\.id)) - var items: [Item] = [.ungrouped] - items += manager.projects.filter { $0.groupID.map { !groupIDs.contains($0) } ?? true }.map { .project($0.id) } + var items: [Item] = manager.projects.filter { $0.groupID.map { !groupIDs.contains($0) } ?? true }.map { .project($0.id) } for group in groupStore.groups { items.append(.group(group.id)) if !group.isCollapsed { items += manager.projects.filter { $0.groupID == group.id }.map { .project($0.id) } } @@ -161,13 +161,6 @@ final class ProjectSidebarNSView: NSView { let row = rows[item] ?? WorkspaceItemView(frame: .zero) if rows[item] == nil { rows[item] = row; document.addSubview(row) } switch item { - case .ungrouped: - row.apply(title: String(localized: "New Ungrouped Project"), - icon: NSImage(systemSymbolName: "arrow.up.left.and.arrow.down.right", accessibilityDescription: nil), - selected: false, sidebar: true, scale: fontScale) - row.onSelect = { [weak manager] in manager?.newProject() } - row.toolTip = String(localized: "New Ungrouped Project") - row.menuItems = { [weak self] in self?.newGroupMenuItems() ?? [] } case .project(let id): guard let project = manager.projects.first(where: { $0.id == id }) else { continue } configure(row, project: project, shortcut: shortcuts[id]) @@ -225,7 +218,7 @@ final class ProjectSidebarNSView: NSView { row.apply(title: group.name, icon: NSImage(systemSymbolName: group.folderPath == nil ? "tray.full" : "folder", accessibilityDescription: nil), selected: manager.selectedProject?.groupID == group.id, group: true, collapsed: group.isCollapsed, - count: count, sidebar: true, scale: fontScale, actionSymbol: "plus", + marker: group.markerColor, count: count, sidebar: true, scale: fontScale, actionSymbol: "plus", actionLabel: String(localized: "New Project in Group"), action: { [weak manager, weak groupStore] in guard let current = groupStore?.group(id: group.id) else { return } manager?.newProject(in: current) @@ -255,6 +248,29 @@ final class ProjectSidebarNSView: NSView { if current.folderPath != nil { items.append(.action(title: String(localized: "Change Folder…")) { [weak self] in self?.changeFolder(group: current) }) } + items += [ + .separator, + .action(title: String(localized: "Set Color Marker…")) { + guard let latest = self.groupStore.group(id: group.id) else { return } + // The shared color panel can outlive a hidden sidebar. + ProjectTabColorPanelController.shared.present( + group: latest, + apply: { [weak groupStore = self.groupStore] color in + guard let groupStore, var updated = groupStore.group(id: group.id) else { return } + updated.markerColor = color + groupStore.update(updated) + }, + hostWindow: row?.window + ) + }, + ] + if current.markerColor != nil { + items.append(.action(title: String(localized: "Remove Color Marker")) { [weak self] in + guard let self, var updated = self.groupStore.group(id: group.id) else { return } + updated.markerColor = nil + self.groupStore.update(updated) + }) + } items += [.separator, .action(title: String(localized: "Remove Group")) { self.manager.deleteProjectGroup(current) }] return items } @@ -287,7 +303,6 @@ final class ProjectSidebarNSView: NSView { switch item { case .project: height = max(38, ceil(31 * fontScale + 8)) case .group: height = max(28, ceil(21 * fontScale + 6)); y += 5 - case .ungrouped: height = max(26, ceil(19 * fontScale + 6)) } row.frame = NSRect(x: 8, y: y, width: rowWidth, height: height) y += height + 3 @@ -313,13 +328,15 @@ final class ProjectSidebarNSView: NSView { func detach() { fpsCounter.stop() + draggedItem = nil + dropItem = nil + isUngroupedDropTarget = false tabDrag.updateSidebarFrames(projects: [:], groups: [:], ungrouped: nil) } private func publishDropFrames() { guard window != nil else { return } var projects: [UUID: CGRect] = [:], groups: [UUID: CGRect] = [:] - var ungrouped: CGRect? for (item, row) in rows { let visible = row.bounds.intersection(row.convert(document.visibleRect, from: document)) guard !visible.isEmpty else { continue } @@ -327,10 +344,11 @@ final class ProjectSidebarNSView: NSView { switch item { case .project(let id): projects[id] = frame case .group(let id): groups[id] = frame - case .ungrouped: ungrouped = frame } } - tabDrag.updateSidebarFrames(projects: projects, groups: groups, ungrouped: ungrouped) + let newProjectButton = footerButtons[0] + tabDrag.updateSidebarFrames(projects: projects, groups: groups, + ungrouped: newProjectButton.workspaceGlobalRect(newProjectButton.bounds)) } private func updateDropHighlights() { @@ -339,11 +357,11 @@ final class ProjectSidebarNSView: NSView { switch (item, tabDrag.drag?.sidebarTarget) { case (.project(let id), .project(let target)): targeted = id == target case (.group(let id), .newProject(let target)): targeted = id == target - case (.ungrouped, .newProject(nil)): targeted = true default: targeted = false } row.isDropTarget = targeted || (item == dropItem && item != draggedItem) } + footerButtons[0].highlight(isUngroupedDropTarget || tabDrag.drag?.sidebarTarget == .newProject(groupID: nil)) } private func item(at event: NSEvent) -> Item? { @@ -354,11 +372,14 @@ final class ProjectSidebarNSView: NSView { } private func updateDrag(item: Item, event: NSEvent) { - guard item != .ungrouped else { return } draggedItem = item dropItem = self.item(at: event) - updateDropHighlights() let point = convert(event.locationInWindow, from: nil) + isUngroupedDropTarget = false + if case .project = item { + isUngroupedDropTarget = footerButtons[0].frame.contains(point) + } + updateDropHighlights() if scrollView.frame.contains(point) { var y = scrollView.contentView.bounds.minY if point.y < scrollView.frame.minY + 20 { y -= 12 } @@ -375,13 +396,15 @@ final class ProjectSidebarNSView: NSView { let project = manager.projects.first(where: { $0.id == id }) { switch target { case .group(let groupID): manager.moveProject(project, to: groupStore.group(id: groupID)) - case .ungrouped: manager.moveProject(project, to: nil) case .project(let targetID): if targetID != id, let destination = manager.projects.first(where: { $0.id == targetID }) { manager.moveProject(project, to: groupStore.group(id: destination.groupID)) manager.moveProject(id, to: targetID) } - case nil: break + case nil: + if footerButtons[0].frame.contains(convert(event.locationInWindow, from: nil)) { + manager.moveProject(project, to: nil) + } } } else if case .group(let id) = item, case .group(let targetID) = target { groupStore.move(id, to: targetID) @@ -392,6 +415,7 @@ final class ProjectSidebarNSView: NSView { private func cancelDrag() { draggedItem = nil dropItem = nil + isUngroupedDropTarget = false updateDropHighlights() NSCursor.arrow.set() } @@ -458,7 +482,9 @@ final class ProjectSidebarNSView: NSView { ] if project.customName != nil { items.append(.action(title: String(localized: "Use Automatic Title")) { project.customName = nil }) } items += [.separator, .submenu(title: String(localized: "Move to Group"), items: groups), .separator] - items.append(.action(title: String(localized: "Set Color Marker…")) { ProjectTabColorPanelController.shared.present(project: project) }) + items.append(.action(title: String(localized: "Set Color Marker…")) { + ProjectTabColorPanelController.shared.present(project: project, hostWindow: row?.window) + }) if project.markerColor != nil { items.append(.action(title: String(localized: "Remove Color Marker")) { project.markerColor = nil }) } items += [.separator, .action(title: String(localized: "Set Project Directory…"), enabled: !project.isRemote) { [weak self] in self?.pickFolder(initial: project.customDirectory ?? project.selectedSession?.currentDirectoryPath) { path in @@ -477,9 +503,13 @@ final class ProjectSidebarNSView: NSView { panel.allowsMultipleSelection = false panel.prompt = String(localized: "Choose") if let initial { panel.directoryURL = URL(fileURLWithPath: initial, isDirectory: true) } - if let window { - panel.beginSheetModal(for: window) { response in completion(response == .OK ? panel.url?.path : nil) } - } else { completion(panel.runModal() == .OK ? panel.url?.path : nil) } + guard let host = AppWindowPresentation.hostWindow(relativeTo: window) else { + completion(nil) + return + } + panel.beginSheetModal(for: host) { response in + completion(response == .OK ? panel.url?.path : nil) + } } override func draggingEntered(_ sender: NSDraggingInfo) -> NSDragOperation { updateFolderDrop(sender) } diff --git a/mac/zshell/AppKitSessionTabsView.swift b/mac/zshell/AppKitSessionTabsView.swift index e47e120..802e96c 100644 --- a/mac/zshell/AppKitSessionTabsView.swift +++ b/mac/zshell/AppKitSessionTabsView.swift @@ -284,7 +284,7 @@ final class SessionTabsNSView: NSView { row.apply(title: group.name, icon: nil, selected: project.selectedTab?.tabGroupID == group.id, group: true, collapsed: group.isCollapsed, grouped: true, - count: members.count, scale: scale, actionSymbol: "plus", + marker: group.markerColor, count: members.count, scale: scale, actionSymbol: "plus", actionLabel: String(localized: "New Session in Group"), action: { [weak project] in project?.newSession(inTabGroup: group.id) }) row.toolTip = group.name @@ -298,15 +298,36 @@ final class SessionTabsNSView: NSView { } row.menuItems = { [weak row, weak project] in guard let project, let current = project.tabGroup(id: group.id) else { return [] } - return [ + var items: [AppKitContextMenuItem] = [ .action(title: String(localized: "New Session in Group")) { project.newSession(inTabGroup: group.id) }, .action(title: String(localized: "Rename…")) { row?.onRename?() }, .action(title: String(localized: current.isCollapsed ? "Expand Group" : "Collapse Group")) { project.setTabGroupCollapsed(!current.isCollapsed, id: group.id) }, + ] + items += [ + .separator, + .action(title: String(localized: "Set Color Marker…")) { [weak project] in + guard let project, let latest = project.tabGroup(id: group.id) else { return } + ProjectTabColorPanelController.shared.present( + tabGroup: latest, + apply: { [weak project] color in + project?.setTabGroupColor(color, id: group.id) + }, + hostWindow: row?.window + ) + }, + ] + if current.markerColor != nil { + items.append(.action(title: String(localized: "Remove Color Marker")) { + project.setTabGroupColor(nil, id: group.id) + }) + } + items += [ .separator, .action(title: String(localized: "Remove Group")) { project.removeTabGroup(group.id) }, ] + return items } } @@ -469,7 +490,12 @@ final class SessionTabsNSView: NSView { groupItems.append(.action(title: String(localized: "Remove from Group")) { project.moveTab(tab.id, toGroup: nil) }) } items += [.separator, .submenu(title: String(localized: "Move to Group"), items: groupItems), .separator] - items.append(.action(title: String(localized: "Set Color Marker…")) { ProjectTabColorPanelController.shared.present(tab: tab) }) + items.append(.action(title: String(localized: "Set Color Marker…")) { + ProjectTabColorPanelController.shared.present( + tab: tab, + hostWindow: self.rows[.tab(tab.id)]?.window + ) + }) if tab.markerColor != nil { items.append(.action(title: String(localized: "Remove Color Marker")) { tab.markerColor = nil }) } if case .file(let file) = tab.focusedContent { items.append(.action(title: String(localized: "Reveal in Finder")) { NSWorkspace.shared.activateFileViewerSelecting([URL(fileURLWithPath: file.path)]) }) diff --git a/mac/zshell/AppKitWorktreeSectionView.swift b/mac/zshell/AppKitWorktreeSectionView.swift new file mode 100644 index 0000000..4efd62f --- /dev/null +++ b/mac/zshell/AppKitWorktreeSectionView.swift @@ -0,0 +1,334 @@ +// +// AppKitWorktreeSectionView.swift +// zshell +// + +import AppKit +import SwiftUI + +/// Only the mounting boundary is SwiftUI; AppKit owns the header, rows, and +/// bounded scroll viewport, independently of the Git change list below it. +struct GitWorktreeSectionView: NSViewRepresentable { + let worktrees: [GitStatusModel.Worktree] + @Binding var isCollapsed: Bool + let fontScale: CGFloat + let openWorktree: (String) -> Void + + func makeNSView(context: Context) -> GitWorktreeSectionNSView { + GitWorktreeSectionNSView(frame: .zero) + } + + func updateNSView(_ view: GitWorktreeSectionNSView, context: Context) { + view.apply(worktrees: worktrees, isCollapsed: isCollapsed, fontScale: fontScale, + openWorktree: openWorktree, toggleCollapsed: { isCollapsed.toggle() }) + } + + func sizeThatFits(_ proposal: ProposedViewSize, nsView: GitWorktreeSectionNSView, context: Context) -> CGSize? { + let width = proposal.width.flatMap { $0.isFinite ? $0 : nil } ?? 240 + return CGSize(width: width, + height: GitWorktreeSectionMetrics(fontScale: fontScale) + .height(count: worktrees.count, collapsed: isCollapsed)) + } +} + +struct GitWorktreeSectionMetrics { + let sidebar: SidebarLayoutMetrics + init(fontScale: CGFloat) { sidebar = SidebarLayoutMetrics(fontScale: fontScale) } + var headerHeight: CGFloat { + sidebar.lineHeight(designedFontSize: 9.5, weight: .medium, minimum: 16) + 11 + } + var rowHeight: CGFloat { + sidebar.lineHeight(designedFontSize: 11, weight: .medium, minimum: 14) + + sidebar.lineHeight(designedFontSize: 9.5, minimum: 12) + 7 + } + func listHeight(count: Int) -> CGFloat { + min(CGFloat(max(0, count)) * rowHeight, 160 * sidebar.growthScale) + } + func height(count: Int, collapsed: Bool) -> CGFloat { + count == 0 ? 0 : headerHeight + (collapsed ? 0 : listHeight(count: count)) + } +} + +final class GitWorktreeSectionNSView: NSView, NSTableViewDataSource, NSTableViewDelegate { + private let header = NSButton(title: String(localized: "WORKTREES"), target: nil, action: nil) + private let countLabel = NSTextField(labelWithString: "") + private let scrollView = GitWorktreeScrollView() + private let tableView = RowButtonTableView() + private var worktrees: [GitStatusModel.Worktree] = [] + private var metrics = GitWorktreeSectionMetrics(fontScale: 1) + private var isCollapsed = false + private var openWorktree: ((String) -> Void)? + private var toggleCollapsed: (() -> Void)? + override var isFlipped: Bool { true } + override var intrinsicContentSize: NSSize { + NSSize(width: NSView.noIntrinsicMetric, height: metrics.height(count: worktrees.count, collapsed: isCollapsed)) + } + + override init(frame frameRect: NSRect) { + super.init(frame: frameRect) + header.isBordered = false + header.alignment = .left + header.imagePosition = .imageLeading + header.target = self + header.action = #selector(toggleSection) + countLabel.alignment = .right + countLabel.textColor = .tertiaryLabelColor + countLabel.setAccessibilityElement(false) + tableView.headerView = nil + tableView.backgroundColor = .clear + tableView.intercellSpacing = .zero + tableView.style = .fullWidth + tableView.columnAutoresizingStyle = .lastColumnOnlyAutoresizingStyle + tableView.selectionHighlightStyle = .regular + tableView.dataSource = self + tableView.delegate = self + tableView.target = self + tableView.action = #selector(openSelectedWorktree) + tableView.setAccessibilityLabel(String(localized: "WORKTREES")) + let column = NSTableColumn(identifier: NSUserInterfaceItemIdentifier("worktree")) + column.minWidth = 0 + tableView.addTableColumn(column) + scrollView.documentView = tableView + scrollView.drawsBackground = false + scrollView.borderType = .noBorder + scrollView.hasVerticalScroller = true + scrollView.hasHorizontalScroller = false + scrollView.autohidesScrollers = true + scrollView.scrollerStyle = .overlay + scrollView.verticalScrollElasticity = .none + scrollView.horizontalScrollElasticity = .none + scrollView.automaticallyAdjustsContentInsets = false + for view in [header, countLabel, scrollView] { addSubview(view) } + setAccessibilityElement(false) + setContentHuggingPriority(.required, for: .vertical) + setContentCompressionResistancePriority(.required, for: .vertical) + } + + required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } + + func apply( + worktrees: [GitStatusModel.Worktree], isCollapsed: Bool, fontScale: CGFloat, + openWorktree: @escaping (String) -> Void, toggleCollapsed: @escaping () -> Void + ) { + let nextMetrics = GitWorktreeSectionMetrics(fontScale: fontScale) + let changed = self.worktrees != worktrees || metrics.sidebar.fontScale != nextMetrics.sidebar.fontScale + let differentRepository = !self.worktrees.isEmpty + && !worktrees.contains { next in self.worktrees.contains { $0.path == next.path } } + self.worktrees = worktrees + self.isCollapsed = isCollapsed + self.metrics = nextMetrics + self.openWorktree = openWorktree + self.toggleCollapsed = toggleCollapsed + let scale = metrics.sidebar.fontScale + header.font = .systemFont(ofSize: 9.5 * scale, weight: .medium) + header.contentTintColor = .secondaryLabelColor + header.image = NSImage(systemSymbolName: isCollapsed ? "chevron.right" : "chevron.down", accessibilityDescription: nil)? + .withSymbolConfiguration(.init(pointSize: 7 * scale, weight: .semibold)) + header.setAccessibilityLabel(String(localized: "WORKTREES") + ", \(worktrees.count)") + header.setAccessibilityValue(String(localized: isCollapsed ? "Collapsed" : "Expanded")) + countLabel.font = .monospacedDigitSystemFont(ofSize: 9 * scale, weight: .medium) + countLabel.stringValue = String(worktrees.count) + scrollView.isHidden = isCollapsed || worktrees.isEmpty + tableView.rowHeight = metrics.rowHeight + if changed { tableView.reloadData() } + else { updateVisibleRows() } + if differentRepository { scrollView.contentView.scroll(to: .zero) } + invalidateIntrinsicContentSize() + needsLayout = true + } + + override func layout() { + super.layout() + let countWidth = ceil(countLabel.intrinsicContentSize.width) + let labelHeight = metrics.headerHeight - 11 + header.frame = NSRect(x: 8, y: 8, width: max(0, bounds.width - countWidth - 24), height: labelHeight) + countLabel.frame = NSRect(x: max(8, bounds.width - countWidth - 8), y: 8, + width: countWidth, height: labelHeight) + let listHeight = isCollapsed ? 0 : min( + metrics.listHeight(count: worktrees.count), + max(0, bounds.height - metrics.headerHeight) + ) + scrollView.frame = NSRect(x: 0, y: metrics.headerHeight, width: bounds.width, height: listHeight) + let contentWidth = max(0, scrollView.contentSize.width) + let contentHeight = max(listHeight, CGFloat(worktrees.count) * metrics.rowHeight) + tableView.frame = NSRect(x: 0, y: 0, width: contentWidth, height: contentHeight) + tableView.tableColumns.first?.width = contentWidth + // A shorter refreshed list must not leave an empty viewport at the old offset. + let maximum = max(0, contentHeight - scrollView.contentSize.height) + let offset = min(max(0, scrollView.contentView.bounds.minY), maximum) + scrollView.contentView.scroll(to: NSPoint(x: 0, y: offset)) + scrollView.reflectScrolledClipView(scrollView.contentView) + } + + func numberOfRows(in tableView: NSTableView) -> Int { worktrees.count } + + func tableView(_ tableView: NSTableView, rowViewForRow row: Int) -> NSTableRowView? { + GitWorktreeTableRowView(frame: .zero) + } + + func tableView(_ tableView: NSTableView, viewFor tableColumn: NSTableColumn?, row: Int) -> NSView? { + let identifier = NSUserInterfaceItemIdentifier("worktreeRow") + let cell = tableView.makeView(withIdentifier: identifier, owner: self) as? GitWorktreeRowButton + ?? GitWorktreeRowButton(frame: .zero) + cell.identifier = identifier + configure(cell, row: row) + return cell + } + + private func configure(_ cell: GitWorktreeRowButton, row: Int) { + guard worktrees.indices.contains(row) else { return } + let worktree = worktrees[row] + cell.apply(worktree: worktree, metrics: metrics) { [weak self] in + self?.openWorktree?(worktree.path) + } + } + + private func updateVisibleRows() { + let range = tableView.rows(in: tableView.visibleRect) + guard range.location != NSNotFound else { return } + for index in range.location..= 0 ? tableView.clickedRow : tableView.selectedRow + guard worktrees.indices.contains(index), !worktrees[index].isBare else { return } + openWorktree?(worktrees[index].path) + } +} + +/// Consume boundary scrolls here rather than handing them to a parent scroll +/// view, including when a refresh leaves too few rows to need scrolling. +private final class GitWorktreeScrollView: NSScrollView { + override func scrollWheel(with event: NSEvent) { + let maximum = max(0, (documentView?.bounds.height ?? 0) - contentSize.height) + let offset = contentView.bounds.minY + guard maximum > 0, + !(offset <= 0 && event.scrollingDeltaY > 0), + !(offset >= maximum && event.scrollingDeltaY < 0) else { return } + super.scrollWheel(with: event) + } +} + +private final class GitWorktreeTableRowView: NSTableRowView { + override func drawSelection(in dirtyRect: NSRect) { + // A neutral selection keeps the embedded button labels legible in both appearances. + NSColor.labelColor.withAlphaComponent(0.09).setFill() + NSBezierPath(roundedRect: bounds.insetBy(dx: 3, dy: 1), xRadius: 4, yRadius: 4).fill() + } +} + +private final class GitWorktreeRowButton: NSButton { + private let branchLabel = NSTextField(labelWithString: "") + private let pathLabel = NSTextField(labelWithString: "") + private let currentLabel = NSTextField(labelWithString: String(localized: "Current")) + private let folderIcon = NSImageView() + private let openIcon = NSImageView() + private var metrics = GitWorktreeSectionMetrics(fontScale: 1) + private var openWorktree: (() -> Void)? + private var isHovered = false + override var isFlipped: Bool { true } + + override init(frame frameRect: NSRect) { + super.init(frame: frameRect) + title = "" + isBordered = false + setButtonType(.momentaryChange) + target = self + action = #selector(openClicked) + for label in [branchLabel, pathLabel, currentLabel] { + label.maximumNumberOfLines = 1 + label.lineBreakMode = .byTruncatingMiddle + } + pathLabel.lineBreakMode = .byTruncatingHead + for view in [branchLabel, pathLabel, currentLabel, folderIcon, openIcon] { + view.setAccessibilityElement(false) + addSubview(view) + } + setAccessibilityElement(true) + } + + required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } + + func apply(worktree: GitStatusModel.Worktree, metrics: GitWorktreeSectionMetrics, open: @escaping () -> Void) { + self.metrics = metrics + openWorktree = open + let scale = metrics.sidebar.fontScale + branchLabel.stringValue = worktree.branch ?? String(localized: "Detached HEAD") + branchLabel.font = .systemFont(ofSize: 11 * scale, weight: .medium) + branchLabel.textColor = .labelColor + pathLabel.stringValue = worktree.path + pathLabel.font = .systemFont(ofSize: 9.5 * scale) + pathLabel.textColor = .secondaryLabelColor + currentLabel.font = .systemFont(ofSize: 8.5 * scale, weight: .medium) + currentLabel.textColor = Theme.accent + currentLabel.isHidden = !worktree.isCurrent + folderIcon.image = NSImage(systemSymbolName: worktree.isBare ? "archivebox" : "folder", accessibilityDescription: nil)? + .withSymbolConfiguration(.init(pointSize: 11 * scale, weight: .medium)) + folderIcon.contentTintColor = worktree.isCurrent ? Theme.accent : .secondaryLabelColor + openIcon.image = NSImage(systemSymbolName: "arrow.up.forward", accessibilityDescription: nil)? + .withSymbolConfiguration(.init(pointSize: 9 * scale, weight: .medium)) + openIcon.contentTintColor = .tertiaryLabelColor + isEnabled = !worktree.isBare + alphaValue = worktree.isBare ? 0.55 : 1 + toolTip = worktree.isBare ? String(localized: "Bare repositories do not have a working directory") + : String(localized: "Open Worktree in New Tab") + "\n" + worktree.path + setAccessibilityLabel(branchLabel.stringValue + ", " + worktree.path + + (worktree.isCurrent ? ", " + String(localized: "Current") : "")) + setAccessibilityHelp(worktree.isBare ? String(localized: "Bare repositories cannot be opened in a terminal tab") + : String(localized: "Opens a new terminal tab in this worktree")) + needsLayout = true + } + + override func layout() { + super.layout() + let iconSize = metrics.sidebar.iconSize(14) + let leading = 8 + iconSize + 7 + let available = max(0, bounds.width - leading - iconSize - 16) + let titleHeight = metrics.sidebar.lineHeight(designedFontSize: 11, weight: .medium, minimum: 14) + let pathHeight = metrics.sidebar.lineHeight(designedFontSize: 9.5, minimum: 12) + let top = (bounds.height - titleHeight - pathHeight - 1) / 2 + let currentWidth = currentLabel.isHidden ? 0 : min(available, ceil(currentLabel.intrinsicContentSize.width)) + let branchWidth = max(0, available - currentWidth - (currentWidth > 0 ? 5 : 0)) + folderIcon.frame = NSRect(x: 8, y: (bounds.height - iconSize) / 2, width: iconSize, height: iconSize) + branchLabel.frame = NSRect(x: leading, y: top, width: branchWidth, height: titleHeight) + currentLabel.frame = NSRect(x: leading + available - currentWidth, y: top, width: currentWidth, height: titleHeight) + pathLabel.frame = NSRect(x: leading, y: top + titleHeight + 1, width: available, height: pathHeight) + openIcon.frame = NSRect(x: bounds.width - iconSize - 8, y: (bounds.height - iconSize) / 2, + width: iconSize, height: iconSize) + } + + override func hitTest(_ point: NSPoint) -> NSView? { super.hitTest(point) == nil ? nil : self } + + override func updateTrackingAreas() { + super.updateTrackingAreas() + trackingAreas.forEach(removeTrackingArea) + addTrackingArea(NSTrackingArea(rect: .zero, + options: [.activeInKeyWindow, .mouseEnteredAndExited, .inVisibleRect], owner: self)) + } + override func mouseEntered(with event: NSEvent) { isHovered = true; needsDisplay = true } + override func mouseExited(with event: NSEvent) { isHovered = false; needsDisplay = true } + override func draw(_ dirtyRect: NSRect) { + if isEnabled && (isHovered || isHighlighted) { + NSColor.labelColor.withAlphaComponent(isHighlighted ? 0.09 : 0.05).setFill() + NSBezierPath(roundedRect: bounds.insetBy(dx: 3, dy: 1), xRadius: 4, yRadius: 4).fill() + } + super.draw(dirtyRect) + } + @objc private func openClicked() { if isEnabled { openWorktree?() } } +} diff --git a/mac/zshell/AppWindowPresentation.swift b/mac/zshell/AppWindowPresentation.swift new file mode 100644 index 0000000..27d195d --- /dev/null +++ b/mac/zshell/AppWindowPresentation.swift @@ -0,0 +1,138 @@ +// +// AppWindowPresentation.swift +// zshell +// + +import AppKit + +/// Keeps ordinary AppKit windows attached to the Zshell window that opened +/// them. A missing host is treated as "do not present" rather than falling +/// back to the pointer screen or activating another application. +@MainActor +enum AppWindowPresentation { + enum Placement { + case centered + case topCentered(CGFloat) + } + + static func hostWindow(relativeTo preferred: NSWindow? = nil) -> NSWindow? { + for candidate in [preferred, NSApp.keyWindow, NSApp.mainWindow].compactMap({ $0 }) { + if let host = applicationWindow(owning: candidate) { + return host + } + } + return nil + } + + static func attach( + _ child: NSWindow, + to host: NSWindow, + placement: Placement + ) { + if child.parent !== host { + child.parent?.removeChildWindow(child) + host.addChildWindow(child, ordered: .above) + } + + child.level = .normal + if let panel = child as? NSPanel { + panel.isFloatingPanel = false + } + var behavior = child.collectionBehavior + behavior.remove(.canJoinAllSpaces) + behavior.remove(.canJoinAllApplications) + behavior.remove(.fullScreenAuxiliary) + behavior.remove(.moveToActiveSpace) + child.collectionBehavior = behavior + position(child, relativeTo: host, placement: placement) + } + + static func hideChild(_ child: NSWindow) { + child.parent?.removeChildWindow(child) + child.orderOut(nil) + } + + /// A singleton sheet can outlive the window that first presented it. End + /// that relationship before attaching it to the newly focused Zshell + /// window, otherwise AppKit keeps the sheet logically owned by the old + /// window even when it is ordered to the front. + static func presentSheet(_ sheet: NSWindow, on host: NSWindow) { + guard sheet !== host else { + sheet.makeKeyAndOrderFront(nil) + return + } + + if let parent = sheet.sheetParent, parent !== host { + parent.endSheet(sheet) + DispatchQueue.main.async { [weak sheet, weak host] in + guard let sheet, let host, + sheet.sheetParent == nil, + host.isVisible, + !host.isMiniaturized else { return } + host.beginSheet(sheet) + } + } else if sheet.sheetParent == nil { + host.beginSheet(sheet) + } else { + sheet.makeKeyAndOrderFront(nil) + } + } + + static func position( + _ child: NSWindow, + relativeTo host: NSWindow, + placement: Placement + ) { + let size = child.frame.size + let hostFrame = host.frame + let desiredY: CGFloat + switch placement { + case .centered: + desiredY = hostFrame.midY - size.height / 2 + case .topCentered(let offset): + desiredY = hostFrame.maxY - offset - size.height + } + let origin = NSPoint( + x: clampedOrigin( + desired: hostFrame.midX - size.width / 2, + minimum: hostFrame.minX + 16, + maximum: hostFrame.maxX - size.width - 16 + ), + y: clampedOrigin( + desired: desiredY, + minimum: hostFrame.minY + 16, + maximum: hostFrame.maxY - size.height - 16 + ) + ) + child.setFrameOrigin(origin) + } + + private static func clampedOrigin( + desired: CGFloat, + minimum: CGFloat, + maximum: CGFloat + ) -> CGFloat { + guard minimum <= maximum else { return desired } + return min(max(desired, minimum), maximum) + } + + private static func applicationWindow(owning window: NSWindow) -> NSWindow? { + var current: NSWindow? = window + var visited = Set() + while let candidate = current, + visited.insert(ObjectIdentifier(candidate)).inserted { + if isApplicationWindow(candidate), + candidate.isVisible, + !candidate.isMiniaturized { + return candidate + } + current = candidate.sheetParent ?? candidate.parent + } + return nil + } + + private static func isApplicationWindow(_ window: NSWindow) -> Bool { + guard let identifier = window.identifier?.rawValue else { return false } + return identifier == "settings" || identifier.hasPrefix("main") + } +} diff --git a/mac/zshell/BrowserView.swift b/mac/zshell/BrowserView.swift index 1707018..d5dd31d 100644 --- a/mac/zshell/BrowserView.swift +++ b/mac/zshell/BrowserView.swift @@ -712,11 +712,11 @@ final class BrowserTab: NSObject, ObservableObject, Identifiable, WKNavigationDe for webView: WKWebView, completion: @escaping (NSApplication.ModalResponse) -> Void ) { - if let window = webView.window { - alert.beginSheetModal(for: window, completionHandler: completion) - } else { - completion(alert.runModal()) + guard let window = AppWindowPresentation.hostWindow(relativeTo: webView.window) else { + completion(.cancel) + return } + alert.beginSheetModal(for: window, completionHandler: completion) } } diff --git a/mac/zshell/ContentView.swift b/mac/zshell/ContentView.swift index 626749b..710a862 100644 --- a/mac/zshell/ContentView.swift +++ b/mac/zshell/ContentView.swift @@ -213,11 +213,8 @@ final class TabSplitDragCoordinator: ObservableObject { alert.messageText = String(localized: "Couldn’t Move Tab") alert.informativeText = failure.message alert.addButton(withTitle: String(localized: "OK")) - if let window = NSApp.keyWindow ?? NSApp.mainWindow { - alert.beginSheetModal(for: window) - } else { - alert.runModal() - } + guard let window = AppWindowPresentation.hostWindow() else { return } + alert.beginSheetModal(for: window) } private func dropEdge(at location: CGPoint, in frame: CGRect) -> PaneDropEdge { diff --git a/mac/zshell/FileTreeModel.swift b/mac/zshell/FileTreeModel.swift index fb07c0d..00da45d 100644 --- a/mac/zshell/FileTreeModel.swift +++ b/mac/zshell/FileTreeModel.swift @@ -330,7 +330,8 @@ final class FileTreeModel: nonisolated ObservableObject { alert.messageText = messageText alert.informativeText = informativeText alert.alertStyle = .warning - alert.runModal() + guard let window = AppWindowPresentation.hostWindow() else { return } + alert.beginSheetModal(for: window) } private func rebuild() { diff --git a/mac/zshell/Project.swift b/mac/zshell/Project.swift index 60f037b..93ff105 100644 --- a/mac/zshell/Project.swift +++ b/mac/zshell/Project.swift @@ -774,8 +774,9 @@ final class Project: nonisolated ObservableObject, nonisolated Identifiable { /// Asks whether to save before discarding an edited file, matching the /// standard macOS Save / Don't Save / Cancel prompt. Presented as a sheet - /// on `window` (app-modal only when there's no window) so it doesn't block - /// the whole app. Returns `true` if the user backed out — Cancel, or a save + /// on the owning Zshell window so it doesn't block the whole app. If no + /// owning window is available, leave the content open and report a + /// cancellation. Returns `true` if the user backed out — Cancel, or a save /// that failed — so a batch close can stop before tearing down other panes. /// /// This is `async` on purpose: awaiting the sheet means each prompt in a @@ -796,12 +797,8 @@ final class Project: nonisolated ObservableObject, nonisolated Identifiable { let cancel = alert.addButton(withTitle: String(localized: "Cancel")) cancel.keyEquivalent = "\u{1b}" - let response: NSApplication.ModalResponse - if let window { - response = await alert.beginSheetModal(for: window) - } else { - response = alert.runModal() - } + guard let host = AppWindowPresentation.hostWindow(relativeTo: window) else { return true } + let response = await alert.beginSheetModal(for: host) switch response { case .alertFirstButtonReturn: // Save @@ -867,6 +864,11 @@ final class Project: nonisolated ObservableObject, nonisolated Identifiable { tabGroups[index].name = name } + func setTabGroupColor(_ color: ProjectTabMarkerColor?, id: UUID) { + guard let index = tabGroups.firstIndex(where: { $0.id == id }) else { return } + tabGroups[index].markerColor = color + } + func setTabGroupCollapsed(_ collapsed: Bool, id: UUID) { guard let index = tabGroups.firstIndex(where: { $0.id == id }), tabGroups[index].isCollapsed != collapsed else { return } @@ -908,8 +910,12 @@ final class Project: nonisolated ObservableObject, nonisolated Identifiable { @discardableResult func newSession(inTabGroup groupID: UUID) -> TerminalSession? { guard tabGroup(id: groupID) != nil else { return nil } - let session = newSession() - if let tab = selectedTab { moveTab(tab.id, toGroup: groupID) } + let session = makeSession(launchSettings: launchSettings) + let tab = makeTab(content: .session(session)) + insertNextToSelected(tab) + // Assign the destination before selection can expand the previous group. + moveTab(tab.id, toGroup: groupID) + selectedTabID = tab.id return session } diff --git a/mac/zshell/ProjectGroup.swift b/mac/zshell/ProjectGroup.swift index ea43477..a37405b 100644 --- a/mac/zshell/ProjectGroup.swift +++ b/mac/zshell/ProjectGroup.swift @@ -18,6 +18,8 @@ struct ProjectGroup: Identifiable, Codable, Equatable { var kind: Kind /// The section renders collapsed in the sidebar. var isCollapsed: Bool + /// Opaque sRGB marker color. Older group files omit this field. + var markerColorHex: String? enum Kind: Codable, Equatable { case plain @@ -28,12 +30,19 @@ struct ProjectGroup: Identifiable, Codable, Equatable { id: UUID = UUID(), name: String, kind: Kind, - isCollapsed: Bool = false + isCollapsed: Bool = false, + markerColorHex: String? = nil ) { self.id = id self.name = name self.kind = kind self.isCollapsed = isCollapsed + self.markerColorHex = markerColorHex + } + + var markerColor: ProjectTabMarkerColor? { + get { markerColorHex.flatMap(ProjectTabMarkerColor.init(hex:)) } + set { markerColorHex = newValue?.hex } } /// The directory a session opened from this group starts in: home for a @@ -146,10 +155,23 @@ struct SessionTabGroup: Identifiable, Codable, Equatable { let id: UUID var name: String var isCollapsed: Bool + /// Opaque sRGB marker color. Older session snapshots omit this field. + var markerColorHex: String? - init(id: UUID = UUID(), name: String, isCollapsed: Bool = false) { + init( + id: UUID = UUID(), + name: String, + isCollapsed: Bool = false, + markerColorHex: String? = nil + ) { self.id = id self.name = name self.isCollapsed = isCollapsed + self.markerColorHex = markerColorHex + } + + var markerColor: ProjectTabMarkerColor? { + get { markerColorHex.flatMap(ProjectTabMarkerColor.init(hex:)) } + set { markerColorHex = newValue?.hex } } } diff --git a/mac/zshell/ProjectTabMarkerColor.swift b/mac/zshell/ProjectTabMarkerColor.swift index ce10710..1b93bdd 100644 --- a/mac/zshell/ProjectTabMarkerColor.swift +++ b/mac/zshell/ProjectTabMarkerColor.swift @@ -14,7 +14,7 @@ struct ProjectTabMarkerColor: Equatable, Sendable { let hex: String - init?(hex rawValue: String) { + nonisolated init?(hex rawValue: String) { let trimmed = rawValue.trimmingCharacters(in: .whitespacesAndNewlines) let value = trimmed.hasPrefix("#") ? String(trimmed.dropFirst()) : trimmed guard value.count == 6, UInt64(value, radix: 16) != nil else { return nil } @@ -42,30 +42,56 @@ struct ProjectTabMarkerColor: Equatable, Sendable { } /// Owns the shared AppKit color panel without introducing another SwiftUI -/// representable. The active project or tab receives continuous color changes. +/// representable. The active project, tab, or group receives color changes. @MainActor final class ProjectTabColorPanelController: NSObject { static let shared = ProjectTabColorPanelController() private var applyColor: ((ProjectTabMarkerColor) -> Void)? - func present(project: Project) { - present(markerColor: project.markerColor) { [weak project] color in - project?.markerColor = color - } + func present(project: Project, hostWindow: NSWindow? = nil) { + present( + markerColor: project.markerColor, + apply: { [weak project] color in + project?.markerColor = color + }, + hostWindow: hostWindow + ) + } + + func present(tab: PaneTab, hostWindow: NSWindow? = nil) { + present( + markerColor: tab.markerColor, + apply: { [weak tab] color in + tab?.markerColor = color + }, + hostWindow: hostWindow + ) } - func present(tab: PaneTab) { - present(markerColor: tab.markerColor) { [weak tab] color in - tab?.markerColor = color - } + func present( + group: ProjectGroup, + apply: @escaping (ProjectTabMarkerColor) -> Void, + hostWindow: NSWindow? = nil + ) { + present(markerColor: group.markerColor, apply: apply, hostWindow: hostWindow) + } + + func present( + tabGroup: SessionTabGroup, + apply: @escaping (ProjectTabMarkerColor) -> Void, + hostWindow: NSWindow? = nil + ) { + present(markerColor: tabGroup.markerColor, apply: apply, hostWindow: hostWindow) } private func present( markerColor: ProjectTabMarkerColor?, - apply: @escaping (ProjectTabMarkerColor) -> Void + apply: @escaping (ProjectTabMarkerColor) -> Void, + hostWindow: NSWindow? ) { let initialColor = markerColor ?? .defaultColor + guard let host = AppWindowPresentation.hostWindow(relativeTo: hostWindow) else { return } applyColor = apply let panel = NSColorPanel.shared @@ -74,6 +100,7 @@ final class ProjectTabColorPanelController: NSObject { panel.color = initialColor.nsColor panel.setTarget(self) panel.setAction(#selector(colorDidChange(_:))) + AppWindowPresentation.attach(panel, to: host, placement: .centered) panel.makeKeyAndOrderFront(nil) } diff --git a/mac/zshell/QuickCommands.swift b/mac/zshell/QuickCommands.swift index b1d0eef..fb883bd 100644 --- a/mac/zshell/QuickCommands.swift +++ b/mac/zshell/QuickCommands.swift @@ -182,12 +182,8 @@ final class QuickCommandEditorWindowController: NSWindowController, tableView.reloadData() select(row: presets.isEmpty ? -1 : min(max(selectedRow, 0), presets.count - 1)) guard let window else { return } - if let parent, window.sheetParent == nil { - parent.beginSheet(window) - } else { - window.makeKeyAndOrderFront(nil) - NSApp.activate() - } + guard let host = AppWindowPresentation.hostWindow(relativeTo: parent) else { return } + AppWindowPresentation.presentSheet(window, on: host) } func numberOfRows(in tableView: NSTableView) -> Int { presets.count } diff --git a/mac/zshell/QuickLaunchEditorController.swift b/mac/zshell/QuickLaunchEditorController.swift index eb24454..70817ee 100644 --- a/mac/zshell/QuickLaunchEditorController.swift +++ b/mac/zshell/QuickLaunchEditorController.swift @@ -61,23 +61,17 @@ final class QuickLaunchEditorController: NSObject, NSWindowDelegate { buildForm() } - /// Presents the editor centered over `parent` (the launcher panel when it - /// is open, else centered on screen). + /// Presents the editor centered in the Zshell window that owns `parent`. static func present(editing entry: QuickLaunchEntry?, relativeTo parent: NSWindow?) { + guard let host = AppWindowPresentation.hostWindow(relativeTo: parent) else { return } let controller = QuickLaunchEditorController(entry: entry) openEditors.append(controller) - if let parent { - let frame = controller.window.frame - controller.window.setFrameOrigin(NSPoint( - x: parent.frame.midX - frame.width / 2, - y: parent.frame.midY - frame.height / 2 - )) - } else { - controller.window.center() - } - // The launcher panel floats, so the editor must float above it. - controller.window.level = .floating + AppWindowPresentation.attach( + controller.window, + to: host, + placement: .centered + ) controller.window.makeKeyAndOrderFront(nil) controller.window.makeFirstResponder(controller.nameField) } diff --git a/mac/zshell/QuickLaunchPanelController.swift b/mac/zshell/QuickLaunchPanelController.swift index 6a09cc2..7db4e24 100644 --- a/mac/zshell/QuickLaunchPanelController.swift +++ b/mac/zshell/QuickLaunchPanelController.swift @@ -8,8 +8,8 @@ import Combine import FuzzyMatch /// The borderless panel hosting Quick Launch. It becomes key — the search -/// field must take typing — while `nonactivatingPanel` keeps the owning app -/// frontmost, matching how Spotlight-style overlays behave. +/// field must take typing — while remaining a child of the owning Zshell +/// window so it cannot float above another application or Space. final class QuickLaunchPanel: NSPanel { override var canBecomeKey: Bool { true } @@ -40,7 +40,7 @@ final class QuickLaunchPanel: NSPanel { weak var controller: QuickLaunchPanelController? } -/// The Quick Launch overlay: a floating panel over the key window listing the +/// The Quick Launch overlay: a child panel over the owning window listing the /// saved command and SSH entries. Typing fuzzy-filters the list, Return /// launches the selection in a new terminal session, and ⌘N / ⌘E / ⌘⌫ manage /// entries. One shared panel; opening again repositions it over the current @@ -59,6 +59,7 @@ final class QuickLaunchPanelController: NSObject { private static let topOffset: CGFloat = 110 private var panel: QuickLaunchPanel? + private weak var hostWindow: NSWindow? private weak var manager: TerminalManager? private let searchField = NSTextField() @@ -141,7 +142,9 @@ final class QuickLaunchPanelController: NSObject { } func show(manager: TerminalManager) { + guard let host = AppWindowPresentation.hostWindow(relativeTo: manager.presentationWindow) else { return } self.manager = manager + hostWindow = host let panel = self.panel ?? makePanel() self.panel = panel @@ -150,43 +153,14 @@ final class QuickLaunchPanelController: NSObject { updateClearButton() refilterAndReload(preservingSelection: false) applyTheme() - position(panel: panel) + AppWindowPresentation.attach(panel, to: host, placement: .topCentered(Self.topOffset)) panel.makeKeyAndOrderFront(nil) panel.makeFirstResponder(searchField) } func close() { - panel?.orderOut(nil) - } - - private func position(panel: NSPanel) { - let size = contentSize() - // Position over the window Quick Launch was invoked from; the panel - // is not key yet at this point, so the key window is that window. - guard let host = NSApp.keyWindow, host !== panel else { - if let screen = NSScreen.main { - panel.setFrame( - NSRect( - x: screen.visibleFrame.midX - size.width / 2, - y: screen.visibleFrame.midY - size.height / 2 + 80, - width: size.width, - height: size.height - ), - display: false - ) - } - return - } - let hostFrame = host.frame - panel.setFrame( - NSRect( - x: hostFrame.midX - size.width / 2, - y: hostFrame.maxY - Self.topOffset - size.height, - width: size.width, - height: size.height - ), - display: false - ) + if let panel { AppWindowPresentation.hideChild(panel) } + hostWindow = nil } private func contentSize() -> NSSize { @@ -207,7 +181,7 @@ final class QuickLaunchPanelController: NSObject { private func makePanel() -> QuickLaunchPanel { let panel = QuickLaunchPanel( contentRect: NSRect(origin: .zero, size: contentSize()), - styleMask: [.borderless, .nonactivatingPanel], + styleMask: [.borderless], backing: .buffered, defer: false ) @@ -217,8 +191,8 @@ final class QuickLaunchPanelController: NSObject { panel.hasShadow = true panel.isReleasedWhenClosed = false panel.hidesOnDeactivate = true - panel.level = .floating - panel.collectionBehavior = [.fullScreenAuxiliary] + panel.level = .normal + panel.collectionBehavior = [] let content = QuickLaunchPanelContentView() content.onEffectiveAppearanceChange = { [weak self] in self?.applyTheme() } @@ -564,9 +538,14 @@ final class QuickLaunchPanelController: NSObject { let size = contentSize() listHeightConstraint?.constant = size.height - Self.searchBarHeight - Self.footerHeight - 2 - let top = panel.frame.maxY panel.setContentSize(size) - panel.setFrameOrigin(NSPoint(x: panel.frame.minX, y: top - panel.frame.height)) + if let hostWindow { + AppWindowPresentation.position( + panel, + relativeTo: hostWindow, + placement: .topCentered(Self.topOffset) + ) + } tableView.sizeLastColumnToFit() if displayRows.isEmpty { diff --git a/mac/zshell/RightSidebarView.swift b/mac/zshell/RightSidebarView.swift index ee30da0..09d0714 100644 --- a/mac/zshell/RightSidebarView.swift +++ b/mac/zshell/RightSidebarView.swift @@ -1396,11 +1396,8 @@ private struct GitPanel: View { } } - if let window = NSApp.keyWindow ?? NSApp.mainWindow { - alert.beginSheetModal(for: window, completionHandler: handleResponse) - } else { - handleResponse(alert.runModal()) - } + guard let window = AppWindowPresentation.hostWindow() else { return } + alert.beginSheetModal(for: window, completionHandler: handleResponse) } // MARK: Commit box @@ -1636,27 +1633,12 @@ private struct GitPanel: View { @ViewBuilder private var worktreesSection: some View { if !model.worktrees.isEmpty { - VStack(spacing: 0) { - GitSectionHeader( - title: String(localized: "WORKTREES"), - count: model.worktrees.count, - isCollapsed: $worktreesCollapsed, - actions: [], - actionsDisabled: model.isBusy - ) - if !worktreesCollapsed { - ScrollView { - LazyVStack(spacing: 0) { - ForEach(model.worktrees) { worktree in - WorktreeRow(worktree: worktree) { - openWorktree(worktree.path) - } - } - } - } - } - } - .frame(maxHeight: worktreesCollapsed ? nil : 160) + GitWorktreeSectionView( + worktrees: model.worktrees, + isCollapsed: $worktreesCollapsed, + fontScale: sidebarFontScale, + openWorktree: openWorktree + ) } } @@ -2135,69 +2117,6 @@ private struct GitPanel: View { } } -private struct WorktreeRow: View { - let worktree: GitStatusModel.Worktree - let open: () -> Void - - private var branchLabel: String { - worktree.branch ?? String(localized: "Detached HEAD") - } - - var body: some View { - Button(action: open) { - HStack(spacing: 7) { - Image(systemName: worktree.isBare ? "archivebox" : "folder") - .sidebarFont(size: 11, weight: .medium) - .foregroundStyle(worktree.isCurrent ? Color(nsColor: Theme.accent) : .secondary) - .frame(width: 14) - VStack(alignment: .leading, spacing: 1) { - HStack(spacing: 5) { - Text(verbatim: branchLabel) - .sidebarFont(size: 11, weight: .medium) - .foregroundStyle(.primary) - .lineLimit(1) - .truncationMode(.middle) - if worktree.isCurrent { - Text(String(localized: "Current")) - .sidebarFont(size: 8.5, weight: .medium) - .foregroundStyle(Color(nsColor: Theme.accent)) - } - } - Text(verbatim: worktree.path) - .sidebarFont(size: 9.5) - .foregroundStyle(.secondary) - .lineLimit(1) - .truncationMode(.head) - } - Spacer(minLength: 0) - Image(systemName: "arrow.up.forward") - .sidebarFont(size: 9, weight: .medium) - .foregroundStyle(.tertiary) - } - .frame(maxWidth: .infinity, minHeight: 16, alignment: .leading) - .contentShape(Rectangle()) - } - .buttonStyle(.plain) - .padding(.horizontal, 8) - .padding(.vertical, 3) - .disabled(worktree.isBare) - .opacity(worktree.isBare ? 0.55 : 1) - .help( - worktree.isBare - ? String(localized: "Bare repositories do not have a working directory") - : String(localized: "Open Worktree in New Tab") - ) - .accessibilityLabel( - branchLabel + ", " + worktree.path - ) - .accessibilityHint( - worktree.isBare - ? String(localized: "Bare repositories cannot be opened in a terminal tab") - : String(localized: "Opens a new terminal tab in this worktree") - ) - } -} - private final class NonemptyTextFieldValidator: NSObject, NSTextFieldDelegate { private weak var button: NSButton? diff --git a/mac/zshell/SSHProjectController.swift b/mac/zshell/SSHProjectController.swift index a8953c0..6b0629c 100644 --- a/mac/zshell/SSHProjectController.swift +++ b/mac/zshell/SSHProjectController.swift @@ -167,6 +167,7 @@ final class SSHProjectController: NSObject { private weak var manager: TerminalManager? private var window: NSWindow? + private weak var hostWindow: NSWindow? // A table subclass: without it the table claims every mouse-down // (including ones on the rows' buttons), so the row's edit/delete @@ -221,13 +222,15 @@ final class SSHProjectController: NSObject { private var lifetime: [AnyCancellable] = [] func present(for manager: TerminalManager) { + guard let host = AppWindowPresentation.hostWindow(relativeTo: manager.presentationWindow) else { return } self.manager = manager + hostWindow = host let window = self.window ?? makeWindow() self.window = window clearForm() reloadList() - window.center() + AppWindowPresentation.attach(window, to: host, placement: .centered) window.makeKeyAndOrderFront(nil) window.makeFirstResponder(hostField) } @@ -601,12 +604,13 @@ final class SSHProjectController: NSObject { } private func connect(_ entry: SSHProjectEntry, endpoint: SSHEndpoint? = nil) { - guard window?.isVisible == true, let manager else { return } + guard let window, window.isVisible, let manager else { return } do { let endpoint = try endpoint ?? SSHEndpoint( host: entry.host, user: entry.user, port: entry.port ) - window?.orderOut(nil) + AppWindowPresentation.hideChild(window) + hostWindow = nil manager.newSSHProject( endpoint: endpoint, remoteDirectory: entry.directory diff --git a/mac/zshell/Settings/SettingsGeneralPane.swift b/mac/zshell/Settings/SettingsGeneralPane.swift index 4030e0b..3dc21d7 100644 --- a/mac/zshell/Settings/SettingsGeneralPane.swift +++ b/mac/zshell/Settings/SettingsGeneralPane.swift @@ -113,10 +113,7 @@ final class SettingsGeneralPane: SettingsPaneViewController { alert.informativeText = error.localizedDescription alert.alertStyle = .warning alert.addButton(withTitle: String(localized: "OK")) - if let window = view.window { - alert.beginSheetModal(for: window) - } else { - alert.runModal() - } + guard let window = AppWindowPresentation.hostWindow(relativeTo: view.window) else { return } + alert.beginSheetModal(for: window) } } diff --git a/mac/zshell/SettingsImportExport.swift b/mac/zshell/SettingsImportExport.swift index 33e8410..bc68e77 100644 --- a/mac/zshell/SettingsImportExport.swift +++ b/mac/zshell/SettingsImportExport.swift @@ -136,28 +136,22 @@ enum SettingsImportExport { present(alert) { _ in } } - /// Sheets panels and alerts on the key window, falling back to a modal - /// run when no window can host a sheet — the pattern the project picker - /// in the sidebar uses. + /// Sheets panels and alerts on the active Zshell window. Without an owning + /// application window there is no stable place for an ordinary popup, so + /// the request is ignored instead of falling back to a screen-level modal. private static func present( _ panel: NSSavePanel, completion: @escaping (NSApplication.ModalResponse) -> Void ) { - if let window = NSApp.keyWindow ?? NSApp.mainWindow { - panel.beginSheetModal(for: window, completionHandler: completion) - } else { - completion(panel.runModal()) - } + guard let window = AppWindowPresentation.hostWindow() else { return } + panel.beginSheetModal(for: window, completionHandler: completion) } private static func present( _ alert: NSAlert, completion: @escaping (NSApplication.ModalResponse) -> Void ) { - if let window = NSApp.keyWindow ?? NSApp.mainWindow { - alert.beginSheetModal(for: window, completionHandler: completion) - } else { - completion(alert.runModal()) - } + guard let window = AppWindowPresentation.hostWindow() else { return } + alert.beginSheetModal(for: window, completionHandler: completion) } } diff --git a/mac/zshell/SidebarLayoutMetrics.swift b/mac/zshell/SidebarLayoutMetrics.swift index 328fcfe..eadb3b3 100644 --- a/mac/zshell/SidebarLayoutMetrics.swift +++ b/mac/zshell/SidebarLayoutMetrics.swift @@ -21,10 +21,12 @@ struct SidebarLayoutMetrics { let minimum = CGFloat( AppSettings.sidebarFontSizeRange.lowerBound / AppSettings.defaultSidebarFontSize + * AppSettings.interfaceScaleRange.lowerBound ) let maximum = CGFloat( AppSettings.sidebarFontSizeRange.upperBound / AppSettings.defaultSidebarFontSize + * AppSettings.interfaceScaleRange.upperBound ) self.fontScale = min(max(fontScale, minimum), maximum) growthScale = max(1, self.fontScale) diff --git a/mac/zshell/TerminalEnvironmentEditor.swift b/mac/zshell/TerminalEnvironmentEditor.swift index 1945263..dd49489 100644 --- a/mac/zshell/TerminalEnvironmentEditor.swift +++ b/mac/zshell/TerminalEnvironmentEditor.swift @@ -57,14 +57,16 @@ final class TerminalEnvironmentEditorController: NSWindowController, NSWindowDel self.scope = scope loadValues() guard let window else { return } - let parent = NSApp.keyWindow ?? NSApp.mainWindow + guard let parent = AppWindowPresentation.hostWindow() else { + self.scope = nil + self.parentWindow = nil + return + } parentWindow = parent - if let parent, parent !== window { - parent.beginSheet(window) + if parent !== window { + AppWindowPresentation.presentSheet(window, on: parent) } else { - window.center() window.makeKeyAndOrderFront(nil) - NSApp.activate() } } @@ -295,8 +297,8 @@ final class TerminalEnvironmentEditorController: NSWindowController, NSWindowDel private func closeEditor() { guard let window else { return } - if let parentWindow, window.sheetParent === parentWindow { - parentWindow.endSheet(window) + if let parent = window.sheetParent { + parent.endSheet(window) } else { window.orderOut(nil) } diff --git a/mac/zshell/TerminalManager.swift b/mac/zshell/TerminalManager.swift index 29a8287..aec7c60 100644 --- a/mac/zshell/TerminalManager.swift +++ b/mac/zshell/TerminalManager.swift @@ -156,6 +156,8 @@ final class TerminalManager: nonisolated ObservableObject { /// Window hosting this manager, once SwiftUI has attached its content. /// Finder service requests use it to target the active Zshell window. private weak var window: NSWindow? + /// The host window for singleton panels opened by this manager. + var presentationWindow: NSWindow? { window } /// The untouched project created before the first window appears. A Finder /// request arriving during launch replaces it instead of leaving an extra /// home-directory project beside the requested folder. diff --git a/mac/zshell/WorkspaceChromeViews.swift b/mac/zshell/WorkspaceChromeViews.swift index ce6d77c..816dc7d 100644 --- a/mac/zshell/WorkspaceChromeViews.swift +++ b/mac/zshell/WorkspaceChromeViews.swift @@ -90,6 +90,7 @@ extension NSView { /// Stable row views avoid replacing live field editors when terminal titles /// change. Terminal surfaces are never owned by this chrome. final class WorkspaceItemView: NSView, NSTextFieldDelegate { + private static weak var pendingGroupSelectionOwner: WorkspaceItemView? let titleLabel = NSTextField(labelWithString: "") private let subtitleLabel = NSTextField(labelWithString: "") private let iconView = NSImageView() @@ -115,6 +116,8 @@ final class WorkspaceItemView: NSView, NSTextFieldDelegate { private var mouseOrigin: NSPoint? private var hasDragged = false private var dragCancelMonitor: Any? + private var pendingGroupSelection: DispatchWorkItem? + private var pendingGroupSelectionID: UUID? private var isHovered = false private var isSelected = false private var isGroup = false @@ -207,7 +210,11 @@ final class WorkspaceItemView: NSView, NSTextFieldDelegate { shortcutLabel.textColor = .secondaryLabelColor shortcutLabel.stringValue = shortcut ?? "" actionButton.configure(symbol: actionSymbol, label: actionLabel, pointSize: 9 * scale) - actionButton.onAction = action + actionButton.onAction = { [weak self] in + self?.cancelPendingGroupSelection() + WorkspaceItemView.pendingGroupSelectionOwner?.cancelPendingGroupSelection() + action?() + } if let rollup { badge.apply(phase: rollup.phase, count: rollup.count) badgeWidth = badge.intrinsicContentSize.width + 4 * scale @@ -322,16 +329,22 @@ final class WorkspaceItemView: NSView, NSTextFieldDelegate { } override func mouseDown(with event: NSEvent) { + Self.pendingGroupSelectionOwner?.cancelPendingGroupSelection() if event.modifierFlags.contains(.control) { rightMouseDown(with: event); return } mouseOrigin = event.locationInWindow hasDragged = false - if event.clickCount == 2 { mouseOrigin = nil; onRename?() } + if event.clickCount == 2 { + cancelPendingGroupSelection() + mouseOrigin = nil + onRename?() + } } override func mouseDragged(with event: NSEvent) { guard !isRenaming, let mouseOrigin else { return } if !hasDragged { guard hypot(event.locationInWindow.x - mouseOrigin.x, event.locationInWindow.y - mouseOrigin.y) >= 4 else { return } + cancelPendingGroupSelection() hasDragged = true dragCancelMonitor = NSEvent.addLocalMonitorForEvents(matching: .keyDown) { [weak self] event in let input = WorkspaceChromeEvent(event) @@ -352,7 +365,32 @@ final class WorkspaceItemView: NSView, NSTextFieldDelegate { mouseOrigin = nil removeDragMonitor() if hasDragged { onDragEnded?(event) } - else if bounds.contains(convert(event.locationInWindow, from: nil)) { onSelect?() } + else if bounds.contains(convert(event.locationInWindow, from: nil)) { + // Any row selection supersedes a delayed group selection. + Self.pendingGroupSelectionOwner?.cancelPendingGroupSelection() + if isGroup && onRename != nil { + // A group's first click must not collapse it before a second + // click starts renaming. Ordinary tabs still select immediately. + cancelPendingGroupSelection() + Self.pendingGroupSelectionOwner = self + let selectionID = UUID() + pendingGroupSelectionID = selectionID + let selection = DispatchWorkItem { [weak self] in + guard let self, + self.pendingGroupSelectionID == selectionID, + self.window != nil, + !self.isRenaming else { return } + if Self.pendingGroupSelectionOwner === self { + Self.pendingGroupSelectionOwner = nil + } + self.pendingGroupSelection = nil + self.pendingGroupSelectionID = nil + self.onSelect?() + } + pendingGroupSelection = selection + DispatchQueue.main.asyncAfter(deadline: .now() + NSEvent.doubleClickInterval, execute: selection) + } else { onSelect?() } + } hasDragged = false NSCursor.arrow.set() } @@ -363,17 +401,20 @@ final class WorkspaceItemView: NSView, NSTextFieldDelegate { } override func rightMouseDown(with event: NSEvent) { + Self.pendingGroupSelectionOwner?.cancelPendingGroupSelection() guard let items = menuItems?(), !items.isEmpty else { return } menuPresenter.popUp(items: items, at: convert(event.locationInWindow, from: nil), in: self) } override func accessibilityPerformShowMenu() -> Bool { + Self.pendingGroupSelectionOwner?.cancelPendingGroupSelection() guard let items = menuItems?(), !items.isEmpty else { return false } menuPresenter.popUp(items: items, at: NSPoint(x: bounds.midX, y: bounds.midY), in: self) return true } override func keyDown(with event: NSEvent) { + Self.pendingGroupSelectionOwner?.cancelPendingGroupSelection() if event.keyCode == 109, event.modifierFlags.contains(.shift) { _ = accessibilityPerformShowMenu() return @@ -388,9 +429,28 @@ final class WorkspaceItemView: NSView, NSTextFieldDelegate { } } - override func accessibilityPerformPress() -> Bool { onSelect?(); return onSelect != nil } + override func accessibilityPerformPress() -> Bool { + Self.pendingGroupSelectionOwner?.cancelPendingGroupSelection() + onSelect?() + return onSelect != nil + } + + private func cancelPendingGroupSelection() { + pendingGroupSelection?.cancel() + pendingGroupSelection = nil + pendingGroupSelectionID = nil + if Self.pendingGroupSelectionOwner === self { + Self.pendingGroupSelectionOwner = nil + } + } + + override func viewDidMoveToWindow() { + super.viewDidMoveToWindow() + if window == nil { cancelPendingGroupSelection() } + } private func cancelMouseDrag() { + cancelPendingGroupSelection() mouseOrigin = nil hasDragged = false removeDragMonitor() @@ -404,10 +464,12 @@ final class WorkspaceItemView: NSView, NSTextFieldDelegate { } deinit { + pendingGroupSelection?.cancel() if let dragCancelMonitor { NSEvent.removeMonitor(dragCancelMonitor) } } func beginRename(value: String, commit: @escaping (String) -> Void) { + cancelPendingGroupSelection() guard !isRenaming else { return } renamePreviousResponder = window?.firstResponder renameCommit = commit diff --git a/web/content/docs/git.mdx b/web/content/docs/git.mdx index ae168c3..df07f2f 100644 --- a/web/content/docs/git.mdx +++ b/web/content/docs/git.mdx @@ -91,7 +91,9 @@ choose **Create New Branch…** to start one from where you are. The Git panel lists every worktree belonging to the repository, together with its branch and checkout path. The active checkout is marked **Current**. Click a worktree to open a new terminal tab in that directory, so each checkout keeps its -own shell context while remaining part of the same project. +own shell context while remaining part of the same project. Longer worktree lists +scroll inside their own bounded area; the section header, commit controls, and +changed-file list stay in place. Click the worktree header to collapse or expand it. Detached HEAD worktrees show **Detached HEAD** instead of a branch name. Bare repositories are listed for completeness, but they do not have a working diff --git a/web/content/docs/git.zh.mdx b/web/content/docs/git.zh.mdx index 98c025b..3fde05d 100644 --- a/web/content/docs/git.zh.mdx +++ b/web/content/docs/git.zh.mdx @@ -67,7 +67,7 @@ Diff 上方的控件会全局记住两组选择: ## 工作树 Git 面板会列出这个仓库的所有工作树,以及它们对应的分支和检出路径。当前检出会标记为 -**Current**。点某个工作树,就会在对应目录新开一个终端标签页;每个检出都有自己的 shell 上下文,同时仍属于同一个项目。 +**Current**。点某个工作树,就会在对应目录新开一个终端标签页;每个检出都有自己的 shell 上下文,同时仍属于同一个项目。工作树较多时,列表只在自己的限高区域内上下滚动,标题、提交控件和变更文件列表保持原位。单击工作树标题可以折叠或展开列表。 处于 detached HEAD 状态的工作树会显示 **Detached HEAD**,而不是分支名。裸仓库也会列出,但它没有工作目录,不能在终端标签页中打开。 diff --git a/web/content/docs/projects.mdx b/web/content/docs/projects.mdx index e124ff8..dba2f85 100644 --- a/web/content/docs/projects.mdx +++ b/web/content/docs/projects.mdx @@ -42,9 +42,11 @@ directory still takes priority. Moving a project between groups does not change its running terminals or replace its pinned directory. Drag a project onto a group header or another project to organize it. Drag it -onto **New Ungrouped Project** to remove its group membership. Group headers -can also be dragged to reorder groups. Click a header to collapse it, -double-click to rename it, or use its context menu. **Remove Group** keeps all +onto **+ (New Project)** at the bottom of the sidebar to remove its group +membership. Group headers can also be dragged to reorder groups. Click a header +to collapse or expand it, double-click to rename it, or use its context menu. +Choose **Set Color Marker…** to give the group a color, or **Remove Color Marker** +to clear it. **Remove Group** keeps all of its projects open. Number shortcuts follow the currently visible project rows; next/previous project navigation can reveal members of a collapsed group. @@ -106,10 +108,12 @@ lists groups and **Remove from Group**. Click a group header to collapse or expand it, and double-click to rename it. Collapsing a group keeps the active terminal running and visible; switching to a hidden member expands the group. Removing a group keeps its tabs and sessions open. Pinning a grouped tab moves -it to the fixed section outside the groups. +it to the fixed section outside the groups. Right-click a group header to choose +**Set Color Marker…** or **Remove Color Marker**. Group names, colors, and +collapse state are restored after relaunching Zshell. A tab can also be dropped onto a sidebar project, or onto a sidebar group header -to create a new project inside that group. **New Ungrouped Project** creates a +to create a new project inside that group. Dropping onto **+ (New Project)** at the bottom of the sidebar creates a project outside all sidebar groups. **Move Tab to Project** in the tab menu also reaches other windows. These moves preserve the running terminals and pane layout. Projects must have the same local or SSH location; creating a new @@ -140,7 +144,7 @@ Zshell snapshots your layout as you work, so quitting and reopening gives you ba - every project, in order - every tab, including custom names, pinning, and color markers -- sidebar groups and tab groups, including names, order, membership, and collapse state +- sidebar groups and tab groups, including names, color markers, order, membership, and collapse state - pinned project directories and empty projects - the pane layout inside each tab - which sidebars were open and which right panel was selected diff --git a/web/content/docs/projects.zh.mdx b/web/content/docs/projects.zh.mdx index ef6f253..13fe276 100644 --- a/web/content/docs/projects.zh.mdx +++ b/web/content/docs/projects.zh.mdx @@ -28,7 +28,7 @@ description: Zshell 如何组织你的工作——侧边栏里的项目、作为 点击侧边栏底部的 **新建分组**,可以创建普通分组或文件夹分组。普通分组中新建的终端从用户主目录启动;文件夹分组提供选定的默认目录。手动固定的项目目录始终优先。移动项目到其他分组,不会改变正在运行的终端,也不会覆盖已固定的目录。 -把项目拖到分组标题或另一个项目上即可归组,拖到 **新建未分组项目** 区域可以移出分组。分组标题之间也可以拖拽排序。单击标题折叠或展开,双击重命名,右键打开分组菜单。**移除分组** 会保留其中所有项目。数字快捷键按当前可见项目的顺序排列;上一项、下一项导航可以切到折叠组内的项目,并自动展开该组。 +把项目拖到分组标题或另一个项目上即可归组,拖到侧边栏底部的 **+(新建项目)** 可以移出分组。分组标题之间也可以拖拽排序。单击标题折叠或展开,双击重命名,右键打开分组菜单。通过 **设置颜色标记…** 自定义分组颜色,或用 **移除颜色标记** 清除。**移除分组** 会保留其中所有项目。数字快捷键按当前可见项目的顺序排列;上一项、下一项导航可以切到折叠组内的项目,并自动展开该组。 也可以从 Finder 把文件夹拖到侧栏项目列表。已打开的文件夹会被选中,不会重复创建项目。 @@ -68,9 +68,9 @@ Directory** 则交还自动判断。 点击新建会话按钮旁的 **新建标签页分组**,或右键标签页选择 **移动到分组 → 新建标签页分组**。当前标签会加入新组,并立即进入名称编辑。选中组内标签后新建的会话会留在该组;组标题上的 **+** 可以直接在组内创建会话。 -把标签拖到组标题或另一个标签上即可移动。右键菜单也可以选择目标分组,或 **移出分组**。单击组标题折叠、展开,双击重命名。折叠时当前终端仍会运行并继续显示;切换到隐藏的标签会自动展开其分组。移除分组会保留所有标签与会话。固定组内标签时,该标签会移到分组之外的固定区。 +把标签拖到组标题或另一个标签上即可移动。右键菜单也可以选择目标分组,或 **移出分组**。单击组标题折叠、展开,双击重命名。折叠时当前终端仍会运行并继续显示;切换到隐藏的标签会自动展开其分组。移除分组会保留所有标签与会话。固定组内标签时,该标签会移到分组之外的固定区。右键组标题可以 **设置颜色标记…** 或 **移除颜色标记**。侧栏分组和标签页分组的名称、颜色、折叠状态都会在重启后恢复。 -标签还可以拖到侧栏已有项目,或拖到侧栏分组标题,在该分组内创建新项目。拖到 **新建未分组项目** 区域会创建不属于任何侧栏组的新项目。右键菜单的 **移动标签页到项目** 也支持其他窗口。移动会保留运行中的终端和窗格布局。目标项目必须具有相同的本地或 SSH 位置;从标签创建新项目时会继承来源位置。包含 Diff 的标签保留在原项目中。 +标签还可以拖到侧栏已有项目,或拖到侧栏分组标题,在该分组内创建新项目。拖到侧边栏底部的 **+(新建项目)** 会创建不属于任何侧栏组的新项目。右键菜单的 **移动标签页到项目** 也支持其他窗口。移动会保留运行中的终端和窗格布局。目标项目必须具有相同的本地或 SSH 位置;从标签创建新项目时会继承来源位置。包含 Diff 的标签保留在原项目中。 ### 标签页标题 @@ -90,7 +90,7 @@ Zshell 会随着你的操作持续快照布局,所以退出再打开会拿回 - 每个项目,顺序不变 - 每个标签页,包括自定义名称、固定状态和颜色标记 -- 侧边栏分组与标签页分组的名称、顺序、成员和折叠状态 +- 侧边栏分组与标签页分组的名称、颜色标记、顺序、成员和折叠状态 - 固定的项目目录和空项目 - 每个标签页里的窗格布局 - 哪些侧边栏是打开的、右侧选中的是哪个面板