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
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
2 changes: 2 additions & 0 deletions CHANGELOG.zh-Hans.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 状态徽标),可在一处切换项目、标签页与窗格。

Expand Down
19 changes: 18 additions & 1 deletion Packages/MoriCore/Sources/MoriCore/Models/ToolSettings.swift
Original file line number Diff line number Diff line change
Expand Up @@ -7,24 +7,29 @@ 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 {
case tmuxPath
case lazygitPath
case yaziPath
case applyMoriTmuxDefaults
case worktreeBasePath
}

private static let defaultsKey = "toolSettings"
Expand All @@ -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 {
Expand All @@ -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? {
Expand Down
53 changes: 52 additions & 1 deletion Packages/MoriUI/Sources/MoriUI/GhosttySettingsView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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(
Expand All @@ -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()
}
}
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.";
Expand Down Expand Up @@ -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";
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 以应用语言更改。";
Expand Down Expand Up @@ -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" = "任务";
Expand Down
4 changes: 4 additions & 0 deletions Packages/MoriUI/Sources/MoriUI/SidebarContainerView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)?
Expand All @@ -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,
Expand All @@ -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
Expand Down Expand Up @@ -102,6 +105,7 @@ public struct SidebarContainerView: View {
onShowCreatePanel: onShowCreatePanel,
onRemoveWorktree: onRemoveWorktree,
onRemoveProject: onRemoveProject,
onImportWorktrees: onImportWorktrees,
onEditRemoteProject: onEditRemoteProject,
onCloseWindow: onCloseWindow,
onToggleCollapse: onToggleCollapse,
Expand Down
6 changes: 4 additions & 2 deletions Packages/MoriUI/Sources/MoriUI/WorktreeSidebarView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)?
Expand All @@ -38,9 +39,9 @@ public struct WorktreeSidebarView: View {
@State private var awakenedProjectIds: Set<UUID> = []

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 {
Expand Down Expand Up @@ -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") } }
}
Expand Down
19 changes: 19 additions & 0 deletions Sources/Mori/App/AppDelegate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
},
Expand Down
Loading
Loading