diff --git a/mac/zshell/Localizable.xcstrings b/mac/zshell/Localizable.xcstrings index 4472fbe..af0bdbd 100644 --- a/mac/zshell/Localizable.xcstrings +++ b/mac/zshell/Localizable.xcstrings @@ -1515,6 +1515,22 @@ } } }, + "Change Folder…": { + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "更改文件夹…" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "フォルダを変更…" + } + } + } + }, "Changed": { "localizations": { "ja": { @@ -4197,6 +4213,22 @@ } } }, + "Folder Group…": { + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "文件夹分组…" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "フォルダグループ…" + } + } + } + }, "Folder name": { "localizations": { "ja": { @@ -5464,6 +5496,22 @@ } } }, + "Move to Group": { + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "移到分组" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "グループへ移動" + } + } + } + }, "Move to Trash": { "localizations": { "ja": { @@ -5673,6 +5721,22 @@ } } }, + "New Group": { + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "新建分组" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "新規グループ" + } + } + } + }, "New Project": { "localizations": { "ja": { @@ -5721,6 +5785,22 @@ } } }, + "New Project in Group": { + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "在分组中新建项目" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "グループ内で新規プロジェクト" + } + } + } + }, "New Quick Command": { "localizations": { "ja": { @@ -6869,6 +6949,22 @@ } } }, + "Plain Group": { + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "纯分组" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "シンプルグループ" + } + } + } + }, "Plays the alert sound and uses macOS visual alerts when terminal programs ring the bell": { "localizations": { "ja": { @@ -7673,6 +7769,22 @@ } } }, + "Remove Group": { + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "移除分组" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "グループを削除" + } + } + } + }, "Remove Quick Command": { "localizations": { "ja": { @@ -7705,6 +7817,22 @@ } } }, + "Remove from Group": { + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "从分组移除" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "グループから削除" + } + } + } + }, "Remove intent-to-add for %@": { "localizations": { "ja": { diff --git a/mac/zshell/Project.swift b/mac/zshell/Project.swift index 4625f25..27c01e4 100644 --- a/mac/zshell/Project.swift +++ b/mac/zshell/Project.swift @@ -29,6 +29,11 @@ final class Project: nonisolated ObservableObject, nonisolated Identifiable { /// selected session's working directory, re-derived as the session /// moves (see `panelRoot(followingSessionAt:)`). @Published var customDirectory: String? + /// The sidebar group this project sits under, nil when ungrouped. The + /// group's kind decides where new terminals of the project start: a + /// folder group starts them in its folder (the group's directory sits + /// behind an explicit project directory in the chain below). + @Published var groupID: UUID? /// Launch configuration inherited by terminals created after it changes. /// Existing PTYs intentionally keep the environment they started with. @Published var launchSettings = TerminalLaunchSettings() @@ -90,6 +95,13 @@ final class Project: nonisolated ObservableObject, nonisolated Identifiable { var isRemote: Bool { location.isRemote } + /// The directory the project's sidebar group hands to new terminals + /// (home for a plain group, its folder for a folder group); nil when the + /// project is ungrouped or belongs to a group that no longer exists. + private var groupSessionDirectory: String? { + manager?.projectGroup(id: groupID)?.sessionDirectory + } + /// The declared endpoint for SSH projects, nil for local projects. var remoteEndpoint: SSHEndpoint? { if case .ssh(let endpoint, _) = location { return endpoint } @@ -320,6 +332,7 @@ final class Project: nonisolated ObservableObject, nonisolated Identifiable { case .local: initialDirectory = directory ?? customDirectory + ?? groupSessionDirectory ?? selectedSession?.currentDirectoryPath launchArguments = commandArguments case .ssh(let endpoint, let remoteDirectory): diff --git a/mac/zshell/ProjectGroup.swift b/mac/zshell/ProjectGroup.swift new file mode 100644 index 0000000..69b16df --- /dev/null +++ b/mac/zshell/ProjectGroup.swift @@ -0,0 +1,132 @@ +// +// ProjectGroup.swift +// zshell +// + +import AppKit +import Combine +import Foundation + +/// A user-made grouping of sidebar projects. Two kinds: +/// - `.plain` groups are pure labels; a session opened from them starts in +/// the home directory. +/// - `.folder` groups anchor a folder; a session opened from them — and a +/// new terminal in a project placed inside — starts in that folder. +struct ProjectGroup: Identifiable, Codable, Equatable { + let id: UUID + var name: String + var kind: Kind + /// The section renders collapsed in the sidebar. + var isCollapsed: Bool + + enum Kind: Codable, Equatable { + case plain + case folder(path: String) + } + + init( + id: UUID = UUID(), + name: String, + kind: Kind, + isCollapsed: Bool = false + ) { + self.id = id + self.name = name + self.kind = kind + self.isCollapsed = isCollapsed + } + + /// The directory a session opened from this group starts in: home for a + /// plain group, the anchored folder for a folder group. + var sessionDirectory: String { + switch kind { + case .plain: return NSHomeDirectory() + case .folder(let path): return path + } + } + + /// The anchored folder path, nil for a plain group. + var folderPath: String? { + if case .folder(let path) = kind { return path } + return nil + } + + /// A folder group's display name defaults to the folder's own name so the + /// sidebar reads naturally; an explicit rename wins afterwards. + static func defaultName(for kind: Kind) -> String { + if case .folder(let path) = kind { + return URL(fileURLWithPath: path, isDirectory: true).lastPathComponent + } + return String( + localized: "New Group", + comment: "Default name of a newly created sidebar project group." + ) + } +} + +/// The saved project groups, persisted as JSON under the same Debug/Release- +/// separated directory as the SSH project store. Group membership lives on +/// each `Project` (`groupID`) and survives through the session snapshot. +@MainActor +final class ProjectGroupStore: ObservableObject { + static let shared = ProjectGroupStore() + + @Published private(set) var groups: [ProjectGroup] = [] + + static var fileURL: URL { + AppSettings.configURL + .deletingLastPathComponent() + .appendingPathComponent("project-groups.json") + } + + private init() { + groups = Self.load() + } + + func group(id: UUID?) -> ProjectGroup? { + guard let id else { return nil } + return groups.first { $0.id == id } + } + + func add(_ group: ProjectGroup) { + groups.append(group) + save() + } + + func update(_ group: ProjectGroup) { + guard let index = groups.firstIndex(where: { $0.id == group.id }) else { return } + groups[index] = group + save() + } + + func remove(_ group: ProjectGroup) { + groups.removeAll { $0.id == group.id } + save() + } + + private func save() { + let url = Self.fileURL + do { + let encoder = JSONEncoder() + encoder.outputFormatting = [.prettyPrinted, .sortedKeys] + let data = try encoder.encode(groups) + try FileManager.default.createDirectory( + at: url.deletingLastPathComponent(), + withIntermediateDirectories: true + ) + try data.write(to: url, options: .atomic) + } catch { + NSLog("zshell: failed to write \(url.path): \(error)") + } + } + + private static func load() -> [ProjectGroup] { + guard let data = try? Data(contentsOf: fileURL) else { return [] } + do { + return try JSONDecoder().decode([ProjectGroup].self, from: data) + } catch { + NSLog("zshell: failed to read \(fileURL.path): \(error)") + return [] + } + } +} diff --git a/mac/zshell/SessionStore.swift b/mac/zshell/SessionStore.swift index 67f7fd5..68061a9 100644 --- a/mac/zshell/SessionStore.swift +++ b/mac/zshell/SessionStore.swift @@ -237,6 +237,10 @@ struct SessionSnapshot: Codable { /// automatic (the closest git repository, never persisted). /// Optional so older snapshots still decode. var customDirectory: String? + /// The sidebar project group this project belongs to; nil when + /// ungrouped. Optional so snapshots written before grouping existed + /// still decode. + var groupID: UUID? /// Project values inherited by newly created terminals. Empty settings /// are omitted while decoding still accepts snapshots that lack them. var launchSettings = TerminalLaunchSettings() @@ -247,7 +251,7 @@ struct SessionSnapshot: Codable { var selectedTabIndex: Int? enum CodingKeys: String, CodingKey { - case customName, isPinned, markerColorHex, customDirectory, launchSettings, location, tabs, selectedTabIndex + case customName, isPinned, markerColorHex, customDirectory, groupID, launchSettings, location, tabs, selectedTabIndex } init( @@ -255,6 +259,7 @@ struct SessionSnapshot: Codable { isPinned: Bool = false, markerColorHex: String? = nil, customDirectory: String?, + groupID: UUID? = nil, launchSettings: TerminalLaunchSettings = .init(), location: ProjectLocation? = nil, tabs: [TabSnapshot], @@ -264,6 +269,7 @@ struct SessionSnapshot: Codable { self.isPinned = isPinned self.markerColorHex = markerColorHex self.customDirectory = customDirectory + self.groupID = groupID self.launchSettings = launchSettings self.location = location self.tabs = tabs @@ -278,6 +284,7 @@ struct SessionSnapshot: Codable { customDirectory = try container.decodeIfPresent( String.self, forKey: .customDirectory ) + groupID = try container.decodeIfPresent(UUID.self, forKey: .groupID) launchSettings = try container.decodeIfPresent( TerminalLaunchSettings.self, forKey: .launchSettings ) ?? .init() @@ -298,6 +305,7 @@ struct SessionSnapshot: Codable { } try container.encodeIfPresent(markerColorHex, forKey: .markerColorHex) try container.encodeIfPresent(customDirectory, forKey: .customDirectory) + try container.encodeIfPresent(groupID, forKey: .groupID) if launchSettings != .init() { try container.encode(launchSettings, forKey: .launchSettings) } diff --git a/mac/zshell/SidebarView.swift b/mac/zshell/SidebarView.swift index 2c7da89..93b3643 100644 --- a/mac/zshell/SidebarView.swift +++ b/mac/zshell/SidebarView.swift @@ -13,10 +13,25 @@ struct SidebarView: View { let bottomBarHeight: CGFloat @ObservedObject private var settings = AppSettings.shared @ObservedObject private var themeChanges = Theme.changes + @ObservedObject private var groupStore = ProjectGroupStore.shared @Environment(\.colorScheme) private var colorScheme @AppStorage("leftSidebarWidth") private var width: Double = 220 @State private var draggedProjectID: UUID? @State private var projectFrames: [UUID: CGRect] = [:] + @State private var pendingRenamingGroupID: UUID? + + /// Projects without a group, in sidebar order (pinned first). + private var ungroupedProjects: [(index: Int, project: Project)] { + manager.projects.enumerated().compactMap { entry in + entry.element.groupID == nil + ? (index: entry.offset, project: entry.element) + : nil + } + } + + private func projects(in group: ProjectGroup) -> [Project] { + manager.projects.filter { $0.groupID == group.id } + } private var sidebarWidthRange: ClosedRange { (160 * settings.interfaceScale)...(400 * settings.interfaceScale) @@ -49,14 +64,18 @@ struct SidebarView: View { ScrollView { VStack(spacing: 3) { - ForEach(Array(manager.projects.enumerated()), id: \.element.id) { index, project in + // Ungrouped projects keep the pre-grouping layout: pinned + // rows first, in their existing order, at full indent. + ForEach(ungroupedProjects, id: \.project.id) { entry in + let project = entry.project SidebarProjectRow( project: project, - index: index, + index: entry.index, isSelected: project.id == manager.selectedProjectID, select: { manager.selectedProjectID = project.id }, setPinned: { manager.setPinned($0, for: project) }, close: { manager.close(project) }, + moveToGroup: { manager.moveProject(project, to: $0) }, isDragging: draggedProjectID == project.id, onDrag: { updateProjectDrag(source: project.id, location: $0) }, onDragEnded: endProjectDrag, @@ -71,6 +90,64 @@ struct SidebarView: View { } } } + + // Groups follow, in saved order; each renders a header + // row (click to collapse) over its indented projects. + ForEach(groupStore.groups) { group in + SidebarGroupHeader( + group: group, + projectCount: projects(in: group).count, + isRenaming: pendingRenamingGroupID == group.id, + toggleCollapsed: { + var updated = group + updated.isCollapsed.toggle() + groupStore.update(updated) + }, + beginRename: { pendingRenamingGroupID = group.id }, + endRename: { pendingRenamingGroupID = nil }, + applyRename: { newValue in + var updated = group + updated.name = newValue + groupStore.update(updated) + }, + newProjectInGroup: { manager.newProject(in: group) }, + changeFolder: { + pickGroupFolder(group: group, store: groupStore) + }, + removeGroup: { + for project in projects(in: group) { + manager.moveProject(project, to: nil) + } + groupStore.remove(group) + } + ) + + if !group.isCollapsed { + ForEach(projects(in: group)) { project in + SidebarProjectRow( + project: project, + index: nil, + isSelected: project.id == manager.selectedProjectID, + select: { manager.selectedProjectID = project.id }, + setPinned: { manager.setPinned($0, for: project) }, + close: { manager.close(project) }, + moveToGroup: { manager.moveProject(project, to: $0) }, + isDragging: draggedProjectID == project.id, + onDrag: { updateProjectDrag(source: project.id, location: $0) }, + onDragEnded: endProjectDrag, + fontSize: settings.sidebarFontSize * settings.interfaceScale + ) + .background { + GeometryReader { proxy in + Color.clear.preference( + key: ProjectFramePreferenceKey.self, + value: [project.id: proxy.frame(in: .global)] + ) + } + } + } + } + } } .padding(.horizontal, 8) .padding(.top, 8) @@ -84,6 +161,12 @@ struct SidebarView: View { systemImage: "plus", tooltip: "New Project (⌘N)" ) { manager.newProject() } + SidebarFooterButton( + systemImage: "folder.badge.plus", + tooltip: "New Group" + ) { + showNewGroupMenu() + } SidebarFooterButton( systemImage: "network", tooltip: "New SSH Project" @@ -167,6 +250,104 @@ struct SidebarView: View { draggedProjectID = nil NSCursor.arrow.set() } + + /// The "+" group button: plain groups start empty, folder groups anchor a + /// folder picked here so the group's name and directory both follow it. + private func showNewGroupMenu() { + let menu = NSMenu() + let plain = NSMenuItem( + title: String(localized: "Plain Group", comment: "Menu item creating a sidebar project group without a folder."), + action: #selector(SidebarGroupMenuTarget.newPlainGroup(_:)), + keyEquivalent: "" + ) + plain.target = menuTarget + menu.addItem(plain) + let folder = NSMenuItem( + title: String(localized: "Folder Group…", comment: "Menu item creating a sidebar project group anchored to a folder."), + action: #selector(SidebarGroupMenuTarget.newFolderGroup(_:)), + keyEquivalent: "" + ) + folder.target = menuTarget + menu.addItem(folder) + + menuTarget.kind = .none + menuTarget.completion = { kind in + if case .folder = kind { + pickFolder { path in + guard let path else { return } + let group = ProjectGroup( + name: ProjectGroup.defaultName(for: .folder(path: path)), + kind: .folder(path: path) + ) + groupStore.add(group) + } + } else { + let group = ProjectGroup(name: ProjectGroup.defaultName(for: .plain), kind: .plain) + groupStore.add(group) + } + } + menu.popUp(positioning: nil, at: NSEvent.mouseLocation, in: nil) + } + + /// Lets the user re-anchor a folder group. + private func pickGroupFolder(group: ProjectGroup, store: ProjectGroupStore) { + pickFolder(initial: group.folderPath) { path in + guard let path else { return } + var updated = group + updated.kind = .folder(path: path) + updated.name = ProjectGroup.defaultName(for: .folder(path: path)) + store.update(updated) + } + } + + private func pickFolder( + initial: String? = nil, + completion: @escaping (String?) -> Void + ) { + let panel = NSOpenPanel() + panel.canChooseFiles = false + panel.canChooseDirectories = true + panel.allowsMultipleSelection = false + panel.prompt = String(localized: "Choose", comment: "Button in the project directory picker.") + if let initial { + panel.directoryURL = URL(fileURLWithPath: initial, isDirectory: true) + } + let apply: (NSApplication.ModalResponse) -> Void = { response in + completion(response == .OK ? panel.url?.path : nil) + } + if let window = NSApp.keyWindow ?? NSApp.mainWindow { + panel.beginSheetModal(for: window, completionHandler: apply) + } else { + apply(panel.runModal()) + } + } + + /// Target for the NSMenu shown from the new-group button; SwiftUI menus + /// can't be popped up imperatively, so this tiny object carries the two + /// actions and hands the chosen kind back through `completion`. + @State private var menuTarget = SidebarGroupMenuTarget() +} + +@MainActor +private final class SidebarGroupMenuTarget: NSObject { + enum PendingKind { + case none + case plain + case folder + } + + var kind: PendingKind = .none + var completion: ((PendingKind) -> Void)? + + @objc func newPlainGroup(_ sender: NSMenuItem) { + completion?(.plain) + completion = nil + } + + @objc func newFolderGroup(_ sender: NSMenuItem) { + completion?(.folder) + completion = nil + } } private struct SidebarFolderDropView: NSViewRepresentable { @@ -367,11 +548,16 @@ private struct ProjectFramePreferenceKey: PreferenceKey { private struct SidebarProjectRow: View { @ObservedObject var project: Project @ObservedObject private var themeChanges = Theme.changes - let index: Int + /// Sidebar position for the ⌘N hint; nil for grouped rows, which do not + /// claim a global shortcut slot. + let index: Int? let isSelected: Bool let select: () -> Void let setPinned: (Bool) -> Void let close: () -> Void + /// Reparents the project under another sidebar group; nil removes it + /// from its group. Wired by the owner view. + var moveToGroup: ((ProjectGroup?) -> Void)? let isDragging: Bool let onDrag: (CGPoint) -> Void let onDragEnded: () -> Void @@ -434,6 +620,8 @@ private struct SidebarProjectRow: View { }) } items.append(.separator) + items.append(moveToGroupMenuItem) + items.append(.separator) items.append(.action(title: String(localized: "Set Color Marker…")) { ProjectTabColorPanelController.shared.present(project: project) }) @@ -454,6 +642,31 @@ private struct SidebarProjectRow: View { return items } + /// "Move to Group" submenu: one entry per existing group, plus the + /// remove-from-group action for grouped projects. + private var moveToGroupMenuItem: AppKitContextMenuItem { + let groups = ProjectGroupStore.shared.groups + var entries: [AppKitContextMenuItem] = groups.map { group in + .action( + title: group.name, + enabled: project.groupID != group.id + ) { moveToGroup?(group) } + } + if !groups.isEmpty { + entries.append(.separator) + } + entries.append(.action( + title: String(localized: "Remove from Group", comment: "Menu item taking a project out of its sidebar group."), + enabled: project.groupID != nil + ) { moveToGroup?(nil) } + ) + return .submenu( + title: String(localized: "Move to Group", comment: "Menu item grouping a sidebar project."), + enabled: !groups.isEmpty || project.groupID != nil, + items: entries + ) + } + /// Lets the user pin the project's directory — the root the file tree /// and git panels anchor to instead of the automatic closest-git-repo. private func pickProjectDirectory() { @@ -538,7 +751,7 @@ private struct SidebarProjectRow: View { .contentShape(Rectangle()) } .buttonStyle(.plain) - } else if index < 9, !isRenaming { + } else if let index, index < 9, !isRenaming { Text(verbatim: "⌘\(index + 1)") .font(.system(size: supportingFontSize)) .foregroundStyle(.tertiary) @@ -552,7 +765,8 @@ private struct SidebarProjectRow: View { alignment: .trailing ) } - .padding(.horizontal, 8) + .padding(.leading, index == nil ? 18 : 8) + .padding(.trailing, 8) .padding(.vertical, 6) .contentShape(RoundedRectangle(cornerRadius: 6)) .accessibilityValue(markerAccessibilityValue) @@ -623,3 +837,113 @@ private struct SessionDirectoryLabel: View { } } } +/// Sidebar section header for a project group: collapse toggle, the group's +/// icon (plain tray vs anchored folder), an inline rename field, and the +/// group context menu. The group's kind decides where sessions opened from +/// it start — home for a plain group, its folder for a folder group. +private struct SidebarGroupHeader: View { + let group: ProjectGroup + let projectCount: Int + let isRenaming: Bool + let toggleCollapsed: () -> Void + let beginRename: () -> Void + let endRename: () -> Void + /// Commits a new group name (already trimmed by the caller's store). + let applyRename: (String) -> Void + let newProjectInGroup: () -> Void + let changeFolder: () -> Void + let removeGroup: () -> Void + + @State private var renameDraft = "" + @FocusState private var renameFocused: Bool + + var body: some View { + Group { + if isRenaming { + headerContent + } else { + Button(action: toggleCollapsed) { + headerContent + } + .buttonStyle(.plain) + .onTapGesture(count: 2) { beginRename() } + } + } + .background { + AppKitContextMenuMonitor(items: groupContextMenuItems) + } + } + + private var groupContextMenuItems: [AppKitContextMenuItem] { + var items: [AppKitContextMenuItem] = [ + .action(title: String( + localized: "New Project in Group", + comment: "Group menu item creating a project that opens in the group's directory." + )) { newProjectInGroup() }, + .separator, + .action(title: String(localized: "Rename…"), handler: beginRename), + ] + if group.folderPath != nil { + items.append(.action(title: String( + localized: "Change Folder…", + comment: "Group menu item re-anchoring a folder group to another folder." + )) { changeFolder() }) + } + items.append(contentsOf: [ + .separator, + .action(title: String( + localized: "Remove Group", + comment: "Group menu item deleting the group; its projects stay open, ungrouped." + )) { removeGroup() }, + ]) + return items + } + + private var headerContent: some View { + HStack(spacing: 8) { + Image(systemName: group.isCollapsed ? "chevron.right" : "chevron.down") + .font(.system(size: 8, weight: .semibold)) + .foregroundStyle(.tertiary) + .frame(width: 10) + .accessibilityHidden(true) + + Image(systemName: group.folderPath == nil ? "tray.full" : "folder") + .font(.system(size: 10, weight: .medium)) + .foregroundStyle(.secondary) + .frame(width: max(14, fontSize), alignment: .center) + + if isRenaming { + TextField("", text: $renameDraft) + .textFieldStyle(.plain) + .font(.system(size: fontSize, weight: .medium)) + .focused($renameFocused) + .onSubmit(commitRename) + .onExitCommand { endRename() } + .onChange(of: renameFocused) { + if !renameFocused, isRenaming { commitRename() } + } + } else { + Text(group.name) + .font(.system(size: fontSize, weight: .medium)) + .foregroundStyle(.primary) + .lineLimit(1) + Spacer(minLength: 0) + Text("\(projectCount)") + .font(.system(size: fontSize - 1.5)) + .foregroundStyle(.tertiary) + } + } + .padding(.leading, 8) + .padding(.trailing, 8) + .padding(.vertical, 3) + .contentShape(Rectangle()) + } + + private func commitRename() { + renameDraft = renameDraft.trimmingCharacters(in: .whitespacesAndNewlines) + if !renameDraft.isEmpty { applyRename(renameDraft) } + endRename() + } + + private var fontSize: Double { 10.5 } +} diff --git a/mac/zshell/TerminalManager.swift b/mac/zshell/TerminalManager.swift index 1dcb28c..8337eab 100644 --- a/mac/zshell/TerminalManager.swift +++ b/mac/zshell/TerminalManager.swift @@ -327,6 +327,38 @@ final class TerminalManager: nonisolated ObservableObject { return project } + /// The sidebar group with `id`, if it still exists. + func projectGroup(id: UUID?) -> ProjectGroup? { + ProjectGroupStore.shared.group(id: id) + } + + /// Creates a project inside `group` and selects it. The group decides + /// where the first terminal starts: a plain group opens in the home + /// directory, a folder group in its folder (which also pins the + /// project's directory, anchoring the file tree and git panels). + @discardableResult + func newProject(in group: ProjectGroup) -> Project { + let project = makeProject(createInitialSession: false) + project.groupID = group.id + if case .folder(let path) = group.kind { + project.customDirectory = path + } + project.newSession(directory: group.sessionDirectory) + insert(project) + return project + } + + /// Moves `project` into `group` (nil = out of any group). Moving into a + /// folder group pins the project's directory to the group's folder, so + /// the file tree, git panels, and new terminals follow the group; moving + /// out keeps whatever directory the project already had. + func moveProject(_ project: Project, to group: ProjectGroup?) { + project.groupID = group?.id + if let folder = group?.folderPath { + project.customDirectory = folder + } + } + func promptForSSHProject() { SSHProjectController.shared.present(for: self) } @@ -1415,6 +1447,7 @@ final class TerminalManager: nonisolated ObservableObject { isPinned: project.isPinned, markerColorHex: project.markerColor?.hex, customDirectory: project.customDirectory, + groupID: project.groupID, launchSettings: project.launchSettings, location: project.location, tabs: tabs, @@ -1513,6 +1546,7 @@ final class TerminalManager: nonisolated ObservableObject { project.customName = Project.normalizedCustomName(saved.customName) project.markerColor = saved.markerColorHex.flatMap(ProjectTabMarkerColor.init(hex:)) project.customDirectory = saved.customDirectory + project.groupID = saved.groupID project.launchSettings = saved.launchSettings var restoredContexts: [(tab: PaneTab, sessionIndex: Int)] = [] for savedTab in saved.tabs {