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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
128 changes: 128 additions & 0 deletions mac/zshell/Localizable.xcstrings
Original file line number Diff line number Diff line change
Expand Up @@ -1515,6 +1515,22 @@
}
}
},
"Change Folder…": {
"localizations": {
"zh-Hans": {
"stringUnit": {
"state": "translated",
"value": "更改文件夹…"
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "フォルダを変更…"
}
}
}
},
"Changed": {
"localizations": {
"ja": {
Expand Down Expand Up @@ -4197,6 +4213,22 @@
}
}
},
"Folder Group…": {
"localizations": {
"zh-Hans": {
"stringUnit": {
"state": "translated",
"value": "文件夹分组…"
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "フォルダグループ…"
}
}
}
},
"Folder name": {
"localizations": {
"ja": {
Expand Down Expand Up @@ -5464,6 +5496,22 @@
}
}
},
"Move to Group": {
"localizations": {
"zh-Hans": {
"stringUnit": {
"state": "translated",
"value": "移到分组"
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "グループへ移動"
}
}
}
},
"Move to Trash": {
"localizations": {
"ja": {
Expand Down Expand Up @@ -5673,6 +5721,22 @@
}
}
},
"New Group": {
"localizations": {
"zh-Hans": {
"stringUnit": {
"state": "translated",
"value": "新建分组"
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "新規グループ"
}
}
}
},
"New Project": {
"localizations": {
"ja": {
Expand Down Expand Up @@ -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": {
Expand Down Expand Up @@ -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": {
Expand Down Expand Up @@ -7673,6 +7769,22 @@
}
}
},
"Remove Group": {
"localizations": {
"zh-Hans": {
"stringUnit": {
"state": "translated",
"value": "移除分组"
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "グループを削除"
}
}
}
},
"Remove Quick Command": {
"localizations": {
"ja": {
Expand Down Expand Up @@ -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": {
Expand Down
13 changes: 13 additions & 0 deletions mac/zshell/Project.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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 }
Expand Down Expand Up @@ -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):
Expand Down
132 changes: 132 additions & 0 deletions mac/zshell/ProjectGroup.swift
Original file line number Diff line number Diff line change
@@ -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 []
}
}
}
Loading
Loading