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
8 changes: 6 additions & 2 deletions Packages/MoriUI/Sources/MoriUI/SidebarContainerView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ public struct SidebarContainerView: View {
private let onSendKeys: ((String, String) -> Void)?
private let onUpdateProject: ((Project) -> Void)?
private let onReorderProjects: (([UUID]) -> Void)?
private let isSidebarCollapsed: Bool

public init(
projects: [Project] = [],
Expand All @@ -53,7 +54,8 @@ public struct SidebarContainerView: View {
onRequestPaneOutput: ((String, @escaping (String?) -> Void) -> Void)? = nil,
onSendKeys: ((String, String) -> Void)? = nil,
onUpdateProject: ((Project) -> Void)? = nil,
onReorderProjects: (([UUID]) -> Void)? = nil
onReorderProjects: (([UUID]) -> Void)? = nil,
isSidebarCollapsed: Bool = false
) {
self.projects = projects
self.selectedProjectId = selectedProjectId
Expand All @@ -77,6 +79,7 @@ public struct SidebarContainerView: View {
self.onSendKeys = onSendKeys
self.onUpdateProject = onUpdateProject
self.onReorderProjects = onReorderProjects
self.isSidebarCollapsed = isSidebarCollapsed
}

/// Shared Cmd-hold shortcut hint monitor — one instance for the entire sidebar.
Expand Down Expand Up @@ -106,7 +109,8 @@ public struct SidebarContainerView: View {
onRequestPaneOutput: onRequestPaneOutput,
onSendKeys: onSendKeys,
onUpdateProject: onUpdateProject,
onReorderProjects: onReorderProjects
onReorderProjects: onReorderProjects,
isSidebarCollapsed: isSidebarCollapsed
)
.frame(maxWidth: .infinity, maxHeight: .infinity)
.onAppear {
Expand Down
35 changes: 19 additions & 16 deletions Packages/MoriUI/Sources/MoriUI/WorktreeRowView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -34,22 +34,18 @@ public struct WorktreeRowView: View {

public var body: some View {
Button(action: onSelect) {
HStack(alignment: .center, spacing: MoriTokens.Spacing.lg) {
iconView

VStack(alignment: .leading, spacing: MoriTokens.Spacing.xxs) {
HStack(spacing: MoriTokens.Spacing.sm) {
Text(worktree.name)
.font(MoriTokens.Font.rowTitle)
.foregroundStyle(isSelected ? Color.primary : Color.primary.opacity(0.9))
.lineLimit(1)

if isSelected {
selectedAgentLabel
}
}
HStack(alignment: .center, spacing: MoriTokens.Spacing.md) {
Circle()
.fill(statusDotColor)
.frame(width: MoriTokens.Icon.dot, height: MoriTokens.Icon.dot)

subtitleLine
Text(worktree.name)
.font(MoriTokens.Font.rowTitle)
.foregroundStyle(isSelected ? Color.primary : Color.primary.opacity(0.9))
.lineLimit(1)

if isSelected {
selectedAgentLabel
}

Spacer(minLength: 0)
Expand All @@ -61,7 +57,7 @@ public struct WorktreeRowView: View {
.transition(.opacity)
}
}
.padding(.vertical, 9)
.padding(.vertical, 6)
.padding(.horizontal, MoriTokens.Spacing.lg)
.contentShape(Rectangle())
}
Expand Down Expand Up @@ -90,6 +86,13 @@ public struct WorktreeRowView: View {
}
}

private var statusDotColor: Color {
if worktree.agentState == .waitingForInput { return MoriTokens.Color.attention }
if worktree.agentState == .running { return MoriTokens.Color.success }
if worktree.status == .active { return MoriTokens.Color.success }
return MoriTokens.Color.inactive
}

private var iconView: some View {
ZStack {
RoundedRectangle(cornerRadius: MoriTokens.Icon.worktreeBoxRadius)
Expand Down
182 changes: 94 additions & 88 deletions Packages/MoriUI/Sources/MoriUI/WorktreeSidebarView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ public struct WorktreeSidebarView: View {
private let onUpdateProject: ((Project) -> Void)?
private let onReorderProjects: (([UUID]) -> Void)?
private let shortcutHintsVisible: Bool
private let isSidebarCollapsed: Bool

public init(
projects: [Project] = [],
Expand All @@ -51,7 +52,8 @@ public struct WorktreeSidebarView: View {
onRequestPaneOutput: ((String, @escaping (String?) -> Void) -> Void)? = nil,
onSendKeys: ((String, String) -> Void)? = nil,
onUpdateProject: ((Project) -> Void)? = nil,
onReorderProjects: (([UUID]) -> Void)? = nil
onReorderProjects: (([UUID]) -> Void)? = nil,
isSidebarCollapsed: Bool = false
) {
self.projects = projects
self.selectedProjectId = selectedProjectId
Expand All @@ -76,6 +78,7 @@ public struct WorktreeSidebarView: View {
self.onUpdateProject = onUpdateProject
self.onReorderProjects = onReorderProjects
self.shortcutHintsVisible = shortcutHintsVisible
self.isSidebarCollapsed = isSidebarCollapsed
}

private struct ActiveAgentPaneItem: Identifiable {
Expand Down Expand Up @@ -155,11 +158,17 @@ public struct WorktreeSidebarView: View {
}

public var body: some View {
if isSidebarCollapsed {
collapsedBody
} else {
expandedBody
}
}

private var expandedBody: some View {
VStack(spacing: 0) {
ScrollView(.vertical) {
LazyVStack(alignment: .leading, spacing: MoriTokens.Spacing.md) {
summaryStrip
activeWorktreeSection
projectsSectionHeader

if !isProjectsSectionCollapsed {
Expand Down Expand Up @@ -203,6 +212,23 @@ public struct WorktreeSidebarView: View {
}
}

private var collapsedBody: some View {
VStack(spacing: MoriTokens.Spacing.md) {
ScrollView(.vertical) {
LazyVStack(spacing: MoriTokens.Spacing.lg) {
ForEach(sortedProjects) { project in
collapsedProjectButton(project)
}
}
.padding(.top, MoriTokens.Spacing.lg)
.padding(.bottom, MoriTokens.Spacing.sm)
}

Spacer(minLength: 0)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
}

// MARK: - Project Section

@State private var hoveredProjectId: UUID?
Expand Down Expand Up @@ -272,9 +298,8 @@ public struct WorktreeSidebarView: View {
.transition(.opacity)
}
}
.padding(.horizontal, MoriTokens.Spacing.lg)
.padding(.top, MoriTokens.Sidebar.projectHeaderTop)
.padding(.bottom, MoriTokens.Spacing.md)
.padding(.horizontal, MoriTokens.Spacing.md)
.padding(.vertical, 7)
.background(
RoundedRectangle(cornerRadius: MoriTokens.Radius.small)
.fill(
Expand All @@ -297,10 +322,10 @@ public struct WorktreeSidebarView: View {
: Color.primary.opacity(MoriTokens.Opacity.subtle)
)
.frame(height: 1)
.padding(.horizontal, MoriTokens.Spacing.lg)
.padding(.top, MoriTokens.Spacing.sm)
.padding(.horizontal, MoriTokens.Spacing.md)
.padding(.top, MoriTokens.Spacing.xs)

VStack(alignment: .leading, spacing: MoriTokens.Spacing.xs) {
VStack(alignment: .leading, spacing: 3) {
if projectWorktrees.isEmpty, project.id == selectedProjectId {
Text("No worktrees")
.font(MoriTokens.Font.caption)
Expand All @@ -313,26 +338,26 @@ public struct WorktreeSidebarView: View {
worktreeRow(worktree)
}
}
.padding(.top, MoriTokens.Spacing.sm)
.padding(.bottom, MoriTokens.Spacing.sm)
.padding(.top, MoriTokens.Spacing.xs)
.padding(.bottom, MoriTokens.Spacing.xs)
}
}
.padding(.horizontal, MoriTokens.Spacing.sm)
.padding(.vertical, MoriTokens.Spacing.sm)
.padding(.horizontal, MoriTokens.Spacing.xs)
.padding(.vertical, MoriTokens.Spacing.xs)
.background(
RoundedRectangle(cornerRadius: MoriTokens.Radius.medium)
.fill(
isSelectedProject
? Color.primary.opacity(0.065)
: Color.primary.opacity(MoriTokens.Opacity.quiet)
? Color.primary.opacity(0.04)
: Color.clear
)
)
.overlay {
RoundedRectangle(cornerRadius: MoriTokens.Radius.medium)
.strokeBorder(
isSelectedProject
? MoriTokens.Color.active.opacity(0.35)
: Color.primary.opacity(MoriTokens.Opacity.subtle),
? MoriTokens.Color.active.opacity(0.22)
: Color.primary.opacity(0.04),
lineWidth: 1
)
}
Expand Down Expand Up @@ -390,92 +415,73 @@ public struct WorktreeSidebarView: View {
}
}

private func collapsedProjectButton(_ project: Project) -> some View {
let isSelectedProject = project.id == selectedProjectId

return Button {
onSelectProject?(project.id)
} label: {
ProjectLetterTile(project: project)
.padding(MoriTokens.Spacing.xs)
.background(
RoundedRectangle(cornerRadius: MoriTokens.Radius.small)
.fill(isSelectedProject ? MoriTokens.Color.active.opacity(0.16) : Color.clear)
)
.overlay {
RoundedRectangle(cornerRadius: MoriTokens.Radius.small)
.strokeBorder(
isSelectedProject ? MoriTokens.Color.active.opacity(0.28) : Color.clear,
lineWidth: 1
)
}
}
.buttonStyle(.plain)
.help(project.name)
.accessibilityLabel(project.name)
}

// MARK: - Worktree Row

@ViewBuilder
private func worktreeRow(_ worktree: Worktree) -> some View {
let isSelected = worktree.id == selectedWorktreeId
let worktreeWindows = allWindows(for: worktree)
let detailWindows = visibleDetailWindows(for: worktree)
let agentName = worktreeWindows.first(where: {
$0.detectedAgent != nil || $0.agentState != .none
})?.detectedAgent

VStack(alignment: .leading, spacing: 0) {
WorktreeRowView(
worktree: worktree,
agentName: agentName,
isSelected: isSelected,
onSelect: { onSelectWorktree(worktree.id) },
onRemove: onRemoveWorktree.map { remove in { remove(worktree.id) } }
)
.contextMenu {
let editors = EditorLauncher.installed
if !editors.isEmpty {
ForEach(editors) { editor in
Button {
editor.open(path: worktree.path)
} label: {
Label("Open in \(editor.name)", systemImage: editor.icon)
}
}
Divider()
}

Button {
NSWorkspace.shared.selectFile(nil, inFileViewerRootedAtPath: worktree.path)
} label: {
Label("Reveal in Finder", systemImage: "folder")
}

if !worktree.isMainWorktree, let onRemove = onRemoveWorktree {
Divider()
Button(role: .destructive) {
onRemove(worktree.id)
WorktreeRowView(
worktree: worktree,
agentName: agentName,
isSelected: isSelected,
onSelect: { onSelectWorktree(worktree.id) },
onRemove: onRemoveWorktree.map { remove in { remove(worktree.id) } }
)
.contextMenu {
let editors = EditorLauncher.installed
if !editors.isEmpty {
ForEach(editors) { editor in
Button {
editor.open(path: worktree.path)
} label: {
Label("Remove Worktree…", systemImage: "trash")
Label("Open in \(editor.name)", systemImage: editor.icon)
}
}
Divider()
}

// Show only the most relevant window details for the selected worktree.
if isSelected, !detailWindows.isEmpty {
TreeConnectorGroup(data: detailWindows) { window in
let globalIdx = globalWindowIndices[window.tmuxWindowId]
Group {
if window.detectedAgent != nil || window.agentState != .none {
AgentWindowRowView(
window: window,
projectName: projects.first(where: { $0.id == worktree.projectId })?.name ?? "",
worktreeName: worktree.name,
isSelected: isSelected && window.tmuxWindowId == selectedWindowId,
shortcutIndex: globalIdx,
shortcutHintsVisible: shortcutHintsVisible,
onSelect: { onSelectWindow(window.tmuxWindowId) },
onRequestPaneOutput: onRequestPaneOutput,
onSendKeys: onSendKeys
)
} else {
WindowRowView(
window: window,
isActive: isSelected && window.tmuxWindowId == selectedWindowId,
shortcutIndex: globalIdx,
shortcutHintsVisible: shortcutHintsVisible,
onSelect: { onSelectWindow(window.tmuxWindowId) },
onRequestPaneOutput: onRequestPaneOutput,
onSendKeys: onSendKeys
)
}
}
.contextMenu {
if let onCloseWindow {
Button(role: .destructive) {
onCloseWindow(window.tmuxWindowId)
} label: {
Label("Close Tab", systemImage: "xmark")
}
}
}
Button {
NSWorkspace.shared.selectFile(nil, inFileViewerRootedAtPath: worktree.path)
} label: {
Label("Reveal in Finder", systemImage: "folder")
}

if !worktree.isMainWorktree, let onRemove = onRemoveWorktree {
Divider()
Button(role: .destructive) {
onRemove(worktree.id)
} label: {
Label("Remove Worktree…", systemImage: "trash")
}
}
}
Expand Down
Loading
Loading