diff --git a/CHANGELOG.md b/CHANGELOG.md index 8c48efe..0444e39 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### ✨ Features +- **macOS**: Added a configurable worktree location in Settings → General. New local worktrees are created under this base directory (defaults to `~/.mori`); existing worktrees are left where they are. Remote SSH worktrees still use the repo's parent directory. +- **macOS**: Mori now imports a repo's existing git worktrees. Adding a project pulls in all of its on-disk worktrees automatically (not just the root), and a new "Import Existing Worktrees" item in the project context menu rescans on demand. Already-tracked worktrees are skipped, so re-running only picks up new ones. - **iOS (MoriRemote)**: Reworked the keyboard accessory bar by role and frequency — low-frequency app chrome (switch host, customize keys, detach) folds into one leading "•••" overflow menu; the high-frequency context actions (sessions switcher, tmux) stay pinned; the rest of the bar is typing keys with keyboard-dismiss pinned at the end. Removes the back/gear buttons that sat next to `ctrl`/`esc` and were easy to fat-finger. - **iOS (MoriRemote)**: The sidebar now mirrors the desktop hierarchy — projects group their tmux sessions (branches), and each window lists its panes (with agent-state badges) so you can switch project, tab, and pane from one place. diff --git a/CHANGELOG.zh-Hans.md b/CHANGELOG.zh-Hans.md index 70bd0ed..fd5e675 100644 --- a/CHANGELOG.zh-Hans.md +++ b/CHANGELOG.zh-Hans.md @@ -13,6 +13,8 @@ ### ✨ 新功能 +- **macOS**:在「设置 → 通用」中新增可自定义的 worktree 位置。新建的本地 worktree 会创建在该基础目录下(默认 `~/.mori`);已有的 worktree 保持原位不动。远程 SSH worktree 仍使用仓库的父目录。 +- **macOS**:Mori 现在会导入仓库已有的 git worktree。添加项目时会自动带出磁盘上的全部 worktree(不再只有根目录),项目右键菜单新增「导入已有 Worktree」可随时重新扫描。已跟踪的 worktree 会跳过,重复执行只会拾取新增的。 - **iOS(MoriRemote)**:键盘功能栏按角色和频率重排——低频 app 操作(切主机、自定义键、detach)折进最左的「•••」溢出菜单;高频上下文操作(会话切换器、tmux)常驻;其余为打字键,收键盘键钉在末尾。移除了原先紧贴 `ctrl`/`esc`、容易误触的返回键与齿轮键。 - **iOS(MoriRemote)**:侧栏改为与桌面端一致的层级——项目下分组各自的 tmux 会话(分支),每个窗口列出其窗格(带 agent 状态徽标),可在一处切换项目、标签页与窗格。 diff --git a/Packages/MoriCore/Sources/MoriCore/Models/ToolSettings.swift b/Packages/MoriCore/Sources/MoriCore/Models/ToolSettings.swift index cee3c9f..4ebc5e8 100644 --- a/Packages/MoriCore/Sources/MoriCore/Models/ToolSettings.swift +++ b/Packages/MoriCore/Sources/MoriCore/Models/ToolSettings.swift @@ -7,17 +7,21 @@ public struct ToolSettings: Codable, Equatable, Sendable { public var lazygitPath: String public var yaziPath: String public var applyMoriTmuxDefaults: Bool + /// Custom base directory for new local worktrees. Empty means use the default `~/.mori`. + public var worktreeBasePath: String public init( tmuxPath: String = "", lazygitPath: String = "", yaziPath: String = "", - applyMoriTmuxDefaults: Bool = true + applyMoriTmuxDefaults: Bool = true, + worktreeBasePath: String = "" ) { self.tmuxPath = tmuxPath self.lazygitPath = lazygitPath self.yaziPath = yaziPath self.applyMoriTmuxDefaults = applyMoriTmuxDefaults + self.worktreeBasePath = worktreeBasePath } private enum CodingKeys: String, CodingKey { @@ -25,6 +29,7 @@ public struct ToolSettings: Codable, Equatable, Sendable { case lazygitPath case yaziPath case applyMoriTmuxDefaults + case worktreeBasePath } private static let defaultsKey = "toolSettings" @@ -48,6 +53,7 @@ public struct ToolSettings: Codable, Equatable, Sendable { lazygitPath = try container.decodeIfPresent(String.self, forKey: .lazygitPath) ?? "" yaziPath = try container.decodeIfPresent(String.self, forKey: .yaziPath) ?? "" applyMoriTmuxDefaults = try container.decodeIfPresent(Bool.self, forKey: .applyMoriTmuxDefaults) ?? true + worktreeBasePath = try container.decodeIfPresent(String.self, forKey: .worktreeBasePath) ?? "" } public func encode(to encoder: any Encoder) throws { @@ -56,6 +62,17 @@ public struct ToolSettings: Codable, Equatable, Sendable { try container.encode(lazygitPath, forKey: .lazygitPath) try container.encode(yaziPath, forKey: .yaziPath) try container.encode(applyMoriTmuxDefaults, forKey: .applyMoriTmuxDefaults) + try container.encode(worktreeBasePath, forKey: .worktreeBasePath) + } + + /// Resolved base directory for new local worktrees, expanding `~` and falling + /// back to `~/.mori` when no custom path is configured. + public func resolvedWorktreeBaseDir() -> String { + let trimmed = worktreeBasePath.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { + return (NSHomeDirectory() as NSString).appendingPathComponent(".mori") + } + return NSString(string: trimmed).expandingTildeInPath } public func configuredPath(for command: String) -> String? { diff --git a/Packages/MoriUI/Sources/MoriUI/GhosttySettingsView.swift b/Packages/MoriUI/Sources/MoriUI/GhosttySettingsView.swift index da26f3f..d654143 100644 --- a/Packages/MoriUI/Sources/MoriUI/GhosttySettingsView.swift +++ b/Packages/MoriUI/Sources/MoriUI/GhosttySettingsView.swift @@ -379,7 +379,10 @@ public struct GhosttySettingsView: View { ScrollView { VStack(alignment: .leading, spacing: 20) { switch selectedCategory { - case .general: GeneralSettingsContent() + case .general: GeneralSettingsContent( + toolSettings: $toolSettings, + onWorktreeBasePathChanged: { onToolSettingsChanged?(toolSettings) } + ) case .theme: ThemeSettingsContent(model: $model, availableThemes: availableThemes, onChanged: onChanged) case .fonts: FontSettingsContent(model: $model, onChanged: onChanged) case .cursor: CursorSettingsContent(model: $model, onChanged: onChanged) @@ -573,11 +576,16 @@ private struct GeneralSettingsContent: View { ("简体中文", "zh-Hans"), ] + @Binding var toolSettings: ToolSettings + let onWorktreeBasePathChanged: () -> Void + @State private var selectedLocale: String = { let lang = String.moriLanguage return lang.lowercased().hasPrefix("zh") ? "zh-Hans" : "en" }() + @State private var hasUnappliedChanges = false + var body: some View { SettingsCard { SettingRow( @@ -602,6 +610,49 @@ private struct GeneralSettingsContent: View { .font(.system(size: 11)) .foregroundStyle(.secondary) } + + SettingsCard { + SettingRow( + title: .localized("Worktree location"), + description: .localized("Base directory for new local worktrees. Leave empty to use the default ~/.mori. Existing worktrees are not moved.") + ) { + VStack(alignment: .trailing, spacing: 4) { + TextField("~/.mori", text: $toolSettings.worktreeBasePath) + .textFieldStyle(.roundedBorder) + .frame(width: 280) + .font(.system(size: 12, design: .monospaced)) + .onChange(of: toolSettings.worktreeBasePath) { _, _ in + hasUnappliedChanges = true + } + + Text(String(format: .localized("Resolved: %@"), toolSettings.resolvedWorktreeBaseDir())) + .font(.system(size: 11, design: .monospaced)) + .foregroundStyle(.tertiary) + .frame(width: 280, alignment: .trailing) + .multilineTextAlignment(.trailing) + .textSelection(.enabled) + } + } + + CardDivider() + + HStack { + Button(String.localized("Apply")) { + onWorktreeBasePathChanged() + hasUnappliedChanges = false + } + .buttonStyle(.borderedProminent) + .disabled(!hasUnappliedChanges) + + if hasUnappliedChanges { + Text(String.localized("Unsaved changes")) + .font(.system(size: 11)) + .foregroundStyle(.orange) + } + + Spacer() + } + } } } diff --git a/Packages/MoriUI/Sources/MoriUI/Resources/en.lproj/Localizable.strings b/Packages/MoriUI/Sources/MoriUI/Resources/en.lproj/Localizable.strings index e056123..558a370 100644 --- a/Packages/MoriUI/Sources/MoriUI/Resources/en.lproj/Localizable.strings +++ b/Packages/MoriUI/Sources/MoriUI/Resources/en.lproj/Localizable.strings @@ -94,6 +94,7 @@ "Previous Worktree" = "Previous Worktree"; "Registers an extension in Pi's settings.json for agent start, end, and tool execution events." = "Registers an extension in Pi's settings.json for agent start, end, and tool execution events."; "Remove Project…" = "Remove Project…"; +"Import Existing Worktrees" = "Import Existing Worktrees"; "Remove Worktree…" = "Remove Worktree…"; "Resize Pane" = "Resize Pane"; "Restart Mori to apply language change." = "Restart Mori to apply language change."; @@ -158,6 +159,9 @@ "Apply" = "Apply"; "Unsaved changes" = "Unsaved changes"; "Proxy changes only affect new tabs and panes. Existing shells keep their current environment." = "Proxy changes only affect new tabs and panes. Existing shells keep their current environment."; +"Worktree location" = "Worktree location"; +"Base directory for new local worktrees. Leave empty to use the default ~/.mori. Existing worktrees are not moved." = "Base directory for new local worktrees. Leave empty to use the default ~/.mori. Existing worktrees are not moved."; +"Resolved: %@" = "Resolved: %@"; /* Task Mode Sidebar */ "Tasks" = "Tasks"; diff --git a/Packages/MoriUI/Sources/MoriUI/Resources/zh-Hans.lproj/Localizable.strings b/Packages/MoriUI/Sources/MoriUI/Resources/zh-Hans.lproj/Localizable.strings index e2bf728..98b2c3b 100644 --- a/Packages/MoriUI/Sources/MoriUI/Resources/zh-Hans.lproj/Localizable.strings +++ b/Packages/MoriUI/Sources/MoriUI/Resources/zh-Hans.lproj/Localizable.strings @@ -94,6 +94,7 @@ "Previous Worktree" = "上一个工作区"; "Registers an extension in Pi's settings.json for agent start, end, and tool execution events." = "在 Pi 的 settings.json 中注册扩展,用于代理启动、结束和工具执行事件。"; "Remove Project…" = "移除项目…"; +"Import Existing Worktrees" = "导入已有 Worktree"; "Remove Worktree…" = "移除工作树…"; "Resize Pane" = "调整面板大小"; "Restart Mori to apply language change." = "重启 Mori 以应用语言更改。"; @@ -158,6 +159,9 @@ "Apply" = "应用"; "Unsaved changes" = "未保存的更改"; "Proxy changes only affect new tabs and panes. Existing shells keep their current environment." = "代理更改仅影响新的标签页和面板。已有的 shell 会保留当前环境变量。"; +"Worktree location" = "Worktree 位置"; +"Base directory for new local worktrees. Leave empty to use the default ~/.mori. Existing worktrees are not moved." = "新建本地 worktree 的基础目录。留空则使用默认的 ~/.mori。已有的 worktree 不会被移动。"; +"Resolved: %@" = "解析为:%@"; /* Task Mode Sidebar */ "Tasks" = "任务"; diff --git a/Packages/MoriUI/Sources/MoriUI/SidebarContainerView.swift b/Packages/MoriUI/Sources/MoriUI/SidebarContainerView.swift index 069cfad..b60e4d2 100644 --- a/Packages/MoriUI/Sources/MoriUI/SidebarContainerView.swift +++ b/Packages/MoriUI/Sources/MoriUI/SidebarContainerView.swift @@ -22,6 +22,7 @@ public struct SidebarContainerView: View { private let onShowCreatePanel: (() -> Void)? private let onRemoveWorktree: ((UUID) -> Void)? private let onRemoveProject: ((UUID) -> Void)? + private let onImportWorktrees: ((UUID) -> Void)? private let onEditRemoteProject: ((UUID) -> Void)? private let onCloseWindow: ((String) -> Void)? private let onToggleCollapse: ((UUID) -> Void)? @@ -47,6 +48,7 @@ public struct SidebarContainerView: View { onShowCreatePanel: (() -> Void)? = nil, onRemoveWorktree: ((UUID) -> Void)? = nil, onRemoveProject: ((UUID) -> Void)? = nil, + onImportWorktrees: ((UUID) -> Void)? = nil, onEditRemoteProject: ((UUID) -> Void)? = nil, onCloseWindow: ((String) -> Void)? = nil, onToggleCollapse: ((UUID) -> Void)? = nil, @@ -71,6 +73,7 @@ public struct SidebarContainerView: View { self.onShowCreatePanel = onShowCreatePanel self.onRemoveWorktree = onRemoveWorktree self.onRemoveProject = onRemoveProject + self.onImportWorktrees = onImportWorktrees self.onEditRemoteProject = onEditRemoteProject self.onCloseWindow = onCloseWindow self.onToggleCollapse = onToggleCollapse @@ -102,6 +105,7 @@ public struct SidebarContainerView: View { onShowCreatePanel: onShowCreatePanel, onRemoveWorktree: onRemoveWorktree, onRemoveProject: onRemoveProject, + onImportWorktrees: onImportWorktrees, onEditRemoteProject: onEditRemoteProject, onCloseWindow: onCloseWindow, onToggleCollapse: onToggleCollapse, diff --git a/Packages/MoriUI/Sources/MoriUI/WorktreeSidebarView.swift b/Packages/MoriUI/Sources/MoriUI/WorktreeSidebarView.swift index 249a726..571bed2 100644 --- a/Packages/MoriUI/Sources/MoriUI/WorktreeSidebarView.swift +++ b/Packages/MoriUI/Sources/MoriUI/WorktreeSidebarView.swift @@ -17,6 +17,7 @@ public struct WorktreeSidebarView: View { private let onShowCreatePanel: (() -> Void)? private let onRemoveWorktree: ((UUID) -> Void)? private let onRemoveProject: ((UUID) -> Void)? + private let onImportWorktrees: ((UUID) -> Void)? private let onEditRemoteProject: ((UUID) -> Void)? private let onCloseWindow: ((String) -> Void)? private let onToggleCollapse: ((UUID) -> Void)? @@ -38,9 +39,9 @@ public struct WorktreeSidebarView: View { @State private var awakenedProjectIds: Set = [] public init( - projects: [Project] = [], selectedProjectId: UUID? = nil, worktrees: [Worktree], windows: [RuntimeWindow], panes: [RuntimePane] = [], selectedWorktreeId: UUID?, selectedWindowId: String?, shortcutHintsVisible: Bool = false, onSelectProject: ((UUID) -> Void)? = nil, onSelectWorktree: @escaping (UUID) -> Void, onSelectWindow: @escaping (String) -> Void, onSelectPane: ((String) -> Void)? = nil, onShowCreatePanel: (() -> Void)? = nil, onRemoveWorktree: ((UUID) -> Void)? = nil, onRemoveProject: ((UUID) -> Void)? = nil, onEditRemoteProject: ((UUID) -> Void)? = nil, onCloseWindow: ((String) -> Void)? = nil, onToggleCollapse: ((UUID) -> Void)? = nil, onAddProject: (() -> Void)? = nil, onRequestPaneOutput: ((String, @escaping (String?) -> Void) -> Void)? = nil, onSendKeys: ((String, String) -> Void)? = nil, onUpdateProject: ((Project) -> Void)? = nil, onReorderProjects: (([UUID]) -> Void)? = nil, isSidebarCollapsed: Bool = false + projects: [Project] = [], selectedProjectId: UUID? = nil, worktrees: [Worktree], windows: [RuntimeWindow], panes: [RuntimePane] = [], selectedWorktreeId: UUID?, selectedWindowId: String?, shortcutHintsVisible: Bool = false, onSelectProject: ((UUID) -> Void)? = nil, onSelectWorktree: @escaping (UUID) -> Void, onSelectWindow: @escaping (String) -> Void, onSelectPane: ((String) -> Void)? = nil, onShowCreatePanel: (() -> Void)? = nil, onRemoveWorktree: ((UUID) -> Void)? = nil, onRemoveProject: ((UUID) -> Void)? = nil, onImportWorktrees: ((UUID) -> Void)? = nil, onEditRemoteProject: ((UUID) -> Void)? = nil, onCloseWindow: ((String) -> Void)? = nil, onToggleCollapse: ((UUID) -> Void)? = nil, onAddProject: (() -> Void)? = nil, onRequestPaneOutput: ((String, @escaping (String?) -> Void) -> Void)? = nil, onSendKeys: ((String, String) -> Void)? = nil, onUpdateProject: ((Project) -> Void)? = nil, onReorderProjects: (([UUID]) -> Void)? = nil, isSidebarCollapsed: Bool = false ) { - self.projects = projects; self.selectedProjectId = selectedProjectId; self.worktrees = worktrees; self.windows = windows; self.panes = panes; self.selectedWorktreeId = selectedWorktreeId; self.selectedWindowId = selectedWindowId; self.onSelectProject = onSelectProject; self.onSelectWorktree = onSelectWorktree; self.onSelectWindow = onSelectWindow; self.onSelectPane = onSelectPane; self.onShowCreatePanel = onShowCreatePanel; self.onRemoveWorktree = onRemoveWorktree; self.onRemoveProject = onRemoveProject; self.onEditRemoteProject = onEditRemoteProject; self.onCloseWindow = onCloseWindow; self.onToggleCollapse = onToggleCollapse; self.onAddProject = onAddProject; self.onRequestPaneOutput = onRequestPaneOutput; self.onSendKeys = onSendKeys; self.onUpdateProject = onUpdateProject; self.onReorderProjects = onReorderProjects; self.shortcutHintsVisible = shortcutHintsVisible; self.isSidebarCollapsed = isSidebarCollapsed + self.projects = projects; self.selectedProjectId = selectedProjectId; self.worktrees = worktrees; self.windows = windows; self.panes = panes; self.selectedWorktreeId = selectedWorktreeId; self.selectedWindowId = selectedWindowId; self.onSelectProject = onSelectProject; self.onSelectWorktree = onSelectWorktree; self.onSelectWindow = onSelectWindow; self.onSelectPane = onSelectPane; self.onShowCreatePanel = onShowCreatePanel; self.onRemoveWorktree = onRemoveWorktree; self.onRemoveProject = onRemoveProject; self.onImportWorktrees = onImportWorktrees; self.onEditRemoteProject = onEditRemoteProject; self.onCloseWindow = onCloseWindow; self.onToggleCollapse = onToggleCollapse; self.onAddProject = onAddProject; self.onRequestPaneOutput = onRequestPaneOutput; self.onSendKeys = onSendKeys; self.onUpdateProject = onUpdateProject; self.onReorderProjects = onReorderProjects; self.shortcutHintsVisible = shortcutHintsVisible; self.isSidebarCollapsed = isSidebarCollapsed } public var body: some View { @@ -228,6 +229,7 @@ public struct WorktreeSidebarView: View { Divider(); Button { NSWorkspace.shared.selectFile(nil, inFileViewerRootedAtPath: project.repoRootPath) } label: { Label("Reveal in Finder", systemImage: "folder") } Divider(); Button { renameText = project.name; renamingProjectId = project.id } label: { Label("Rename Project…", systemImage: "pencil") } Button { var updated = project; updated.isFavorite.toggle(); onUpdateProject?(updated) } label: { Label(project.isFavorite ? String.localized("Unpin Project") : String.localized("Pin Project"), systemImage: project.isFavorite ? "pin.slash" : "pin.fill") } + if let onImportWorktrees, project.gitCommonDir != project.repoRootPath { Button { onImportWorktrees(project.id) } label: { Label("Import Existing Worktrees", systemImage: "square.and.arrow.down") } } if case .ssh = (project.location ?? .local), let onEditRemoteProject { Button { onEditRemoteProject(project.id) } label: { Label("Update Remote Credentials…", systemImage: "key") } } if let onRemoveProject { Divider(); Button(role: .destructive) { onRemoveProject(project.id) } label: { Label("Remove Project…", systemImage: "trash") } } } diff --git a/Sources/Mori/App/AppDelegate.swift b/Sources/Mori/App/AppDelegate.swift index 35ab2e1..a43ed70 100644 --- a/Sources/Mori/App/AppDelegate.swift +++ b/Sources/Mori/App/AppDelegate.swift @@ -163,6 +163,25 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent await manager.removeProject(projectId: projectId) } }, + onImportWorktrees: { [weak manager] projectId in + guard let manager else { return } + Task { @MainActor in + let alert = NSAlert() + do { + let count = try await manager.importExistingWorktrees(projectId: projectId) + alert.messageText = count > 0 + ? String(format: .localized("Imported %d worktree(s)."), count) + : .localized("No new worktrees found.") + alert.alertStyle = .informational + } catch { + alert.messageText = .localized("Failed to import worktrees.") + alert.informativeText = error.localizedDescription + alert.alertStyle = .warning + } + alert.addButton(withTitle: .localized("OK")) + alert.runModal() + } + }, onEditRemoteProject: { [weak self] projectId in self?.showEditRemoteCredentialsPanel(projectId: projectId) }, diff --git a/Sources/Mori/App/HostingControllers.swift b/Sources/Mori/App/HostingControllers.swift index 9ff152e..129d386 100644 --- a/Sources/Mori/App/HostingControllers.swift +++ b/Sources/Mori/App/HostingControllers.swift @@ -28,6 +28,7 @@ final class SidebarHostingController: NSHostingController { onShowCreatePanel: (() -> Void)? = nil, onRemoveWorktree: ((UUID) -> Void)? = nil, onRemoveProject: ((UUID) -> Void)? = nil, + onImportWorktrees: ((UUID) -> Void)? = nil, onEditRemoteProject: ((UUID) -> Void)? = nil, onCloseWindow: ((String) -> Void)? = nil, onToggleCollapse: ((UUID) -> Void)? = nil, @@ -48,6 +49,7 @@ final class SidebarHostingController: NSHostingController { onShowCreatePanel: onShowCreatePanel, onRemoveWorktree: onRemoveWorktree, onRemoveProject: onRemoveProject, + onImportWorktrees: onImportWorktrees, onEditRemoteProject: onEditRemoteProject, onCloseWindow: onCloseWindow, onToggleCollapse: onToggleCollapse, @@ -95,6 +97,7 @@ struct SidebarContentView: View { let onShowCreatePanel: (() -> Void)? let onRemoveWorktree: ((UUID) -> Void)? let onRemoveProject: ((UUID) -> Void)? + let onImportWorktrees: ((UUID) -> Void)? let onEditRemoteProject: ((UUID) -> Void)? let onCloseWindow: ((String) -> Void)? let onToggleCollapse: ((UUID) -> Void)? @@ -120,6 +123,7 @@ struct SidebarContentView: View { onShowCreatePanel: onShowCreatePanel, onRemoveWorktree: onRemoveWorktree, onRemoveProject: onRemoveProject, + onImportWorktrees: onImportWorktrees, onEditRemoteProject: onEditRemoteProject, onCloseWindow: onCloseWindow, onToggleCollapse: onToggleCollapse, diff --git a/Sources/Mori/App/WorkspaceManager.swift b/Sources/Mori/App/WorkspaceManager.swift index 7e7098a..90370be 100644 --- a/Sources/Mori/App/WorkspaceManager.swift +++ b/Sources/Mori/App/WorkspaceManager.swift @@ -697,12 +697,87 @@ final class WorkspaceManager { // Refresh state try loadAll() + // Pull in any worktrees that already exist on disk for this repo, so a + // freshly added project shows all of its branches, not just the root. + if try await git.isGitRepo(path: path) { + _ = try? await importExistingWorktrees(projectId: project.id) + } + // Select the new project selectProject(project.id) return project } + /// Scan the project's git repo for worktrees that exist on disk but aren't + /// tracked yet, and import them into the workspace. Returns the count imported. + /// + /// Idempotent: already-tracked paths (including the main worktree) and bare + /// entries are skipped, so re-running only picks up newly created worktrees. + @discardableResult + func importExistingWorktrees(projectId: UUID) async throws -> Int { + guard let project = appState.projects.first(where: { $0.id == projectId }) else { + throw WorkspaceError.projectNotFound + } + let projectLocation = location(for: project) + let git = gitBackend(for: projectLocation) + guard try await git.isGitRepo(path: project.repoRootPath) else { return 0 } + + let infos = try await git.listWorktrees(repoPath: project.repoRootPath) + let knownPaths = Set( + appState.worktrees + .filter { $0.projectId == projectId } + .map { Self.normalizeWorktreePath($0.path) } + ) + + let tmux = tmuxBackend(for: projectLocation) + var imported: [Worktree] = [] + for info in infos where !info.isBare { + if knownPaths.contains(Self.normalizeWorktreePath(info.path)) { continue } + + let name = info.branchName ?? (info.path as NSString).lastPathComponent + let sessionName = SessionNaming.sessionName(projectShortName: project.shortName, worktree: name) + let worktree = Worktree( + projectId: projectId, + name: name, + path: info.path, + branch: info.branchName, + headSHA: info.head, + isMainWorktree: false, + isDetached: info.isDetached, + tmuxSessionName: sessionName, + status: .active, + location: projectLocation + ) + try worktreeRepo.save(worktree) + imported.append(worktree) + + // tmux session is best-effort, matching createWorktree() behavior. + let captured = worktree + Task { + _ = try? await tmux.createSession( + name: sessionName, + cwd: info.path, + environment: moriPaneEnvironment(for: captured) + ) + await onSessionCreated?(tmux) + } + } + + appState.worktrees.append(contentsOf: imported) + return imported.count + } + + /// Normalize a worktree path for set membership: expand `~`, collapse `..`, + /// and drop a trailing slash so DB and `git worktree list` paths compare equal. + private static func normalizeWorktreePath(_ path: String) -> String { + var normalized = (path as NSString).standardizingPath + if normalized.count > 1, normalized.hasSuffix("/") { + normalized.removeLast() + } + return normalized + } + @discardableResult func addRemoteProject( host: String, @@ -936,12 +1011,12 @@ final class WorkspaceManager { let branchSlug = SessionNaming.slugify(trimmed) // Compute worktree path. - // Local: ~/.mori/{project-slug}/{branch-slug} + // Local: /{project-slug}/{branch-slug} (defaults to ~/.mori) // Remote SSH: /.mori/{project-slug}/{branch-slug} let projectDir: String switch projectLocation { case .local: - let moriDir = (NSHomeDirectory() as NSString).appendingPathComponent(".mori") + let moriDir = ToolSettings.load().resolvedWorktreeBaseDir() projectDir = (moriDir as NSString).appendingPathComponent(projectSlug) case .ssh: let parentDir = (project.repoRootPath as NSString).deletingLastPathComponent diff --git a/Sources/Mori/Resources/en.lproj/Localizable.strings b/Sources/Mori/Resources/en.lproj/Localizable.strings index 511fcde..49c5bdf 100644 --- a/Sources/Mori/Resources/en.lproj/Localizable.strings +++ b/Sources/Mori/Resources/en.lproj/Localizable.strings @@ -211,6 +211,9 @@ "Replace" = "Replace"; "Cancel" = "Cancel"; "OK" = "OK"; +"Imported %d worktree(s)." = "Imported %d worktree(s)."; +"No new worktrees found." = "No new worktrees found."; +"Failed to import worktrees." = "Failed to import worktrees."; /* Agent command palette */ "Idle" = "Idle"; diff --git a/Sources/Mori/Resources/zh-Hans.lproj/Localizable.strings b/Sources/Mori/Resources/zh-Hans.lproj/Localizable.strings index 4c83ec3..ffa7086 100644 --- a/Sources/Mori/Resources/zh-Hans.lproj/Localizable.strings +++ b/Sources/Mori/Resources/zh-Hans.lproj/Localizable.strings @@ -211,6 +211,9 @@ "Replace" = "替换"; "Cancel" = "取消"; "OK" = "好"; +"Imported %d worktree(s)." = "已导入 %d 个 worktree。"; +"No new worktrees found." = "未发现新的 worktree。"; +"Failed to import worktrees." = "导入 worktree 失败。"; /* Agent command palette */ "Idle" = "空闲";