From 139b7a0cf8e2541eb947aa80c03f42732e911fcf Mon Sep 17 00:00:00 2001 From: Raman Gupta Date: Mon, 2 Nov 2026 14:27:09 -0500 Subject: [PATCH] feat(ui): tell actions apart by what they do Every row action used to be the same blue link, so viewing, changing git state and GitVantage's own settings read as one list, and a worktree's Folder link went unnoticed. Actions now look like what they do, in the toolbar, the hover lanes, the log view and the bulk bar: git actions are blue buttons, viewing actions grey, GitVantage's own (Alerts, Snooze, Add Repo) amber, destructive ones red and pushed to the far end of their lane. Terminal and "Open in" are one split button. The label opens the pinned launcher (Terminal by default) and the menu lists every launcher, Folder included, with a pin to change the default; Settings has the same choice. Worktree cards get the same control as the toolbar. Snooze is a split button too, replacing its dialog: one click snoozes for the pinned length, the menu has the other lengths and Resume now. While snoozed it shows the time left and opens the menu instead. Worktrees with uncommitted work get an amber outline, and worktrees are listed with uncommitted ones first, then by most recent commit, instead of by folder path. Co-Authored-By: Claude Signed-off-by: Raman Gupta --- .../kotlin/com/gitvantage/app/AppState.kt | 40 ++- .../kotlin/com/gitvantage/app/Launcher.kt | 29 ++ .../com/gitvantage/model/RegistryEntry.kt | 5 + .../kotlin/com/gitvantage/model/Worktrees.kt | 13 + src/main/kotlin/com/gitvantage/ui/Bars.kt | 21 +- .../kotlin/com/gitvantage/ui/Components.kt | 127 +++++++-- .../kotlin/com/gitvantage/ui/DetailPanel.kt | 255 +++++++++--------- src/main/kotlin/com/gitvantage/ui/LogView.kt | 20 +- src/main/kotlin/com/gitvantage/ui/Popups.kt | 34 +-- .../kotlin/com/gitvantage/ui/SplitMenu.kt | 148 ++++++++++ .../kotlin/com/gitvantage/ui/Worktrees.kt | 26 +- .../com/gitvantage/model/WorktreeOrderTest.kt | 35 +++ 12 files changed, 543 insertions(+), 210 deletions(-) create mode 100644 src/main/kotlin/com/gitvantage/app/Launcher.kt create mode 100644 src/main/kotlin/com/gitvantage/ui/SplitMenu.kt create mode 100644 src/test/kotlin/com/gitvantage/model/WorktreeOrderTest.kt diff --git a/src/main/kotlin/com/gitvantage/app/AppState.kt b/src/main/kotlin/com/gitvantage/app/AppState.kt index 0a765b2..1903ad9 100644 --- a/src/main/kotlin/com/gitvantage/app/AppState.kt +++ b/src/main/kotlin/com/gitvantage/app/AppState.kt @@ -29,6 +29,7 @@ import com.gitvantage.git.model.Submodule import com.gitvantage.git.model.WorktreeSource import com.gitvantage.model.FetchPolicy import com.gitvantage.model.Meta +import com.gitvantage.model.inDisplayOrder import com.gitvantage.model.Perf import com.gitvantage.model.RegistryEntry import com.gitvantage.model.Repo @@ -77,7 +78,6 @@ sealed interface Popup { val ids: Set data class Tag(override val ids: Set) : Popup data class Untag(override val ids: Set) : Popup - data class Snooze(override val ids: Set) : Popup data class Remind(override val ids: Set, val text: String, val due: Long?) : Popup /** @@ -1827,6 +1827,42 @@ class AppState(private val scope: CoroutineScope) { fun openGitButler(id: String) { if (!Actions.openGitButler(id)) toast("GitButler not found") } fun openUrl(url: String) { if (!Actions.openUrl(url)) toast("Couldn't open the browser") } + /** What the "Open in" control's main half runs. See [Launcher]. */ + var pinnedLauncher by mutableStateOf(Launcher.fromId(Registry.settings().pinnedLauncher)) + private set + + fun pinLauncher(l: Launcher) { + pinnedLauncher = l + Registry.saveSettings(Registry.settings().copy(pinnedLauncher = l.id)) + } + + /** + * The Snooze button's one-click length: a [Meta.SNOOZE_PRESETS] entry. An unknown label — + * a preset renamed since, or a hand edit — falls back to a day rather than failing to load. + */ + var pinnedSnooze by mutableStateOf(snoozePreset(Registry.settings().pinnedSnooze)) + private set + + fun pinSnooze(label: String) { + pinnedSnooze = snoozePreset(label) + Registry.saveSettings(Registry.settings().copy(pinnedSnooze = pinnedSnooze.first)) + } + + private fun snoozePreset(label: String): Pair = + Meta.SNOOZE_PRESETS.firstOrNull { it.first == label } ?: Meta.SNOOZE_PRESETS.first { it.first == "1 day" } + + /** Snooze [ids] for [ms] from now, or until resumed when null — a [Meta.SNOOZE_PRESETS] length. */ + fun snoozeFor(ids: Collection, ms: Long?) = + setSnoozeUntil(ids, ms?.let { System.currentTimeMillis() + it } ?: Meta.SNOOZE_FOREVER) + + fun launch(l: Launcher, path: String) = when (l) { + Launcher.TERMINAL -> openTerminal(path) + Launcher.IDE -> openIde(path) + Launcher.FOLDER -> openFolder(path) + Launcher.GITBUTLER -> openGitButler(path) + Launcher.GIT_GUI -> openGitGui(path) + } + // ---- branches (detail panel) ---- /** Load the branch list for [id] (the detail panel calls this when it opens a repo). */ @@ -2922,7 +2958,7 @@ class AppState(private val scope: CoroutineScope) { fun worktreeViews(repoId: String, repoSnoozed: Boolean, trees: List): List { if (trees.isEmpty()) return emptyList() val now = System.currentTimeMillis() - return trees.filter { !it.isCurrent && !it.bare }.map { wt -> + return trees.filter { !it.isCurrent && !it.bare }.inDisplayOrder().map { wt -> val alerts = worktreeAlerts(repoId, wt.path) val (snoozed, snoozedFor) = Meta.snoozeState(alerts.snoozeUntilEpoch, now) WorktreeView( diff --git a/src/main/kotlin/com/gitvantage/app/Launcher.kt b/src/main/kotlin/com/gitvantage/app/Launcher.kt new file mode 100644 index 0000000..bcc3ec9 --- /dev/null +++ b/src/main/kotlin/com/gitvantage/app/Launcher.kt @@ -0,0 +1,29 @@ +// SPDX-FileCopyrightText: 2026 Raman Gupta +// SPDX-License-Identifier: GPL-3.0-or-later + +package com.gitvantage.app + +/** + * The programs "Open in ▾" can start for a folder, one of which is pinned as the click on the + * control's main half. + * + * Terminal is pinned out of the box: it's the one reached often enough to have had a link of its + * own before the menu absorbed it. But which launcher is "the one you use" depends on whether you + * live in a terminal or an IDE, so the choice is the user's, and it is app-wide — the toolbar and + * every worktree card name the same program, rather than each remembering its own. + * + * [id] is what `registry.json` stores, so the enum can be renamed without moving anyone's pin. + */ +enum class Launcher(val id: String, val label: String) { + TERMINAL("terminal", "Terminal"), + IDE("ide", "IDE"), + FOLDER("folder", "Folder"), + GITBUTLER("gitbutler", "GitButler"), + GIT_GUI("gitgui", "Git Gui"), + ; + + companion object { + /** An unknown or hand-mangled id falls back to the default rather than failing to load. */ + fun fromId(id: String): Launcher = entries.firstOrNull { it.id == id } ?: TERMINAL + } +} diff --git a/src/main/kotlin/com/gitvantage/model/RegistryEntry.kt b/src/main/kotlin/com/gitvantage/model/RegistryEntry.kt index d2d5086..2989606 100644 --- a/src/main/kotlin/com/gitvantage/model/RegistryEntry.kt +++ b/src/main/kotlin/com/gitvantage/model/RegistryEntry.kt @@ -136,6 +136,11 @@ data class Settings( val fileManagerCommand: String = "", val ideCommand: String = "", val browserCommand: String = "", + // Which "Open in" launcher the control's main half runs — a Launcher id. Not a command, just a + // choice among the built-in launchers, so it carries none of the trust concerns above. + val pinnedLauncher: String = "terminal", + // The Snooze button's one-click length — a Meta.SNOOZE_PRESETS label. + val pinnedSnooze: String = "1 day", // Whether the detail pane's Settings disclosure is open. App-wide rather than per repo: it's a // statement about how you use the pane, not about any one project, and having it remember per // repo would mean the pane's length changed as you arrowed down the list. diff --git a/src/main/kotlin/com/gitvantage/model/Worktrees.kt b/src/main/kotlin/com/gitvantage/model/Worktrees.kt index 174eefd..e3e338b 100644 --- a/src/main/kotlin/com/gitvantage/model/Worktrees.kt +++ b/src/main/kotlin/com/gitvantage/model/Worktrees.kt @@ -82,6 +82,19 @@ data class Worktree( } } +/** + * The order worktrees are listed in: those with uncommitted work first, then by most recent commit. + * + * git's own order is by folder path, which reads as no order at all once the cards are labelled by + * branch — an agent's generated folder name decides where its tree lands. Uncommitted work leads + * because it's the work nothing else is keeping safe, and the reason to look at the list; recency + * after that, because the tree you touched last is the one you're likeliest to go back to. + * + * Stable, so trees that tie (no commit time, say — a missing folder) keep git's path order. + */ +fun List.inDisplayOrder(): List = + sortedWith(compareByDescending { it.dirtyCount > 0 }.thenByDescending { it.lastEpoch ?: Long.MIN_VALUE }) + /** * One changed file inside a worktree, with the diffstat the inline Changes list shows. * diff --git a/src/main/kotlin/com/gitvantage/ui/Bars.kt b/src/main/kotlin/com/gitvantage/ui/Bars.kt index 6402ec6..679a3bf 100644 --- a/src/main/kotlin/com/gitvantage/ui/Bars.kt +++ b/src/main/kotlin/com/gitvantage/ui/Bars.kt @@ -231,19 +231,19 @@ fun BulkActionBar(state: AppState) { Spacer(Modifier.width(4.dp)) BulkBtn("Tag") { state.popup = Popup.Tag(ids) } BulkBtn("Untag") { state.popup = Popup.Untag(ids) } - BulkBtn("Push") { + BulkBtn("Push", RowActionKind.GIT) { state.popup = Popup.Confirm( "Push $n $noun?", "Runs git push in each selected repo that has an upstream.", "Push", danger = false, ) { state.push(ids) } } - BulkBtn("Snooze") { state.popup = Popup.Snooze(ids) } + SnoozeControl(state, ids, SplitSize.BAR, snoozedFor = null) BulkBtn("Remind") { state.popup = Popup.Remind(ids, "", null) } // The one remove button with nothing to read before you press it: a red "Remove" sitting // beside Push and Snooze, above a multi-selection. The confirm dialog explains itself, but // only once the click has already been a small act of faith. HoverTip(REMOVE_KEEPS_FILES) { - BulkBtn("Remove", danger = true) { + BulkBtn("Remove", RowActionKind.DANGER) { state.popup = Popup.Confirm( "Remove $n $noun?", "Stops tracking them. Does not touch the repos on disk.", "Remove", danger = true, @@ -254,15 +254,22 @@ fun BulkActionBar(state: AppState) { } } +/** + * A bulk-bar button. [kind] colours it the way the detail pane colours the same action — a blue + * Push, a red Remove — so selecting several repos doesn't change what an action looks like. + * Tag, Untag and Remind keep the neutral outline: GitVantage's own bookkeeping, but nothing that + * needs to stand out from its neighbours the way silencing alerts does. + */ @Composable -private fun BulkBtn(label: String, danger: Boolean = false, onClick: () -> Unit) { +private fun BulkBtn(label: String, kind: RowActionKind = RowActionKind.VIEW, onClick: () -> Unit) { val shape = RoundedCornerShape(7.dp) + val c = kind.colors Box( - Modifier.clip(shape).background(Tokens.surface, shape) - .border(1.dp, if (danger) Tokens.warnBorder else Tokens.borderD8, shape) + Modifier.clip(shape).background(c.fill, shape) + .border(1.dp, c.border, shape) .onTap(onClick).padding(horizontal = 11.dp, vertical = 5.dp), ) { - Txt(label, 12.sp, if (danger) Tokens.redText else Tokens.text, FontWeight.SemiBold) + Txt(label, 12.sp, c.text, FontWeight.SemiBold) } } diff --git a/src/main/kotlin/com/gitvantage/ui/Components.kt b/src/main/kotlin/com/gitvantage/ui/Components.kt index f8e0694..7330e8d 100644 --- a/src/main/kotlin/com/gitvantage/ui/Components.kt +++ b/src/main/kotlin/com/gitvantage/ui/Components.kt @@ -479,31 +479,99 @@ fun RowActions( modifier: Modifier = Modifier, content: @Composable androidx.compose.foundation.layout.FlowRowScope.() -> Unit, ) { + // The reserved height is a [RowActionKind.GIT] chip's, not a bare link's — otherwise a lane with + // a chip in it would grow by its border the moment it filled. androidx.compose.foundation.layout.FlowRow( - modifier.heightIn(min = 17.dp), + modifier.heightIn(min = 19.dp), horizontalArrangement = androidx.compose.foundation.layout.Arrangement.spacedBy(11.dp), verticalArrangement = androidx.compose.foundation.layout.Arrangement.spacedBy(3.dp), + itemVerticalAlignment = Alignment.CenterVertically, ) { if (visible) content() } } /** - * One action inside a [RowActions] lane: a plain text label in the accent, the same shape the - * stash rows have always used. Bordered pills were doing the work of standing out from a row they - * were permanently part of; once a row only shows its actions when asked, the border is just - * weight. [danger] keeps anything destructive out of the blue reserved for navigation. + * What an action does, which decides how it looks — everywhere: the detail pane's toolbar, the hover + * lanes under it, the log's commit rows, the bulk bar. + * + * The colour goes where the consequence is. Blue — the colour the eye finds first — marks what + * changes git state; showing you something, which costs nothing to click, is neutral grey. A lane of + * identical blue words threw that away and left "Folder", "Alerts ▾" and "Remove" reading as one + * list; the earlier toolbar had it backwards, a blue Log beside a grey Push. + */ +enum class RowActionKind { + /** Shows something or opens something elsewhere; changes nothing. Grey outline. */ + VIEW, + + /** Changes git state — a branch, a checkout, a remote. Blue outline, like Push. */ + GIT, + + /** GitVantage's own settings for this row, not git's. Amber, like Snooze. */ + APP, + + /** Destroys something. Red outline, and last in its lane — at its far end where the lane has room. */ + DANGER, +} + +/** Where a [SplitMenuButton] sits, which sets its size to match its neighbours. */ +enum class SplitSize(val height: Dp, val radius: Dp, val padH: Dp, val font: TextUnit, val weight: FontWeight) { + /** The detail pane's action row, beside Commit and Push. */ + TOOLBAR(33.dp, 8.dp, 12.dp, 12.5.sp, FontWeight.Bold), + + /** The bulk-selection bar. */ + BAR(27.dp, 7.dp, 11.dp, 12.sp, FontWeight.SemiBold), + + /** A [RowActions] hover lane. */ + LANE(19.dp, 5.dp, 7.dp, 11.5.sp, FontWeight.Medium), +} + +/** A kind's text, fill and border. */ +data class ActionColors(val text: Color, val fill: Color, val border: Color) + +val RowActionKind.colors: ActionColors + get() = when (this) { + RowActionKind.VIEW -> ActionColors(Tokens.text2, Tokens.surface, Tokens.borderD8) + RowActionKind.GIT -> ActionColors(Tokens.accent, Tokens.tintBlue, Tokens.accentBorder) + RowActionKind.APP -> ActionColors(Tokens.snoozeBtnText, Tokens.snoozeBtnBg, Tokens.snoozeBtnBorder) + RowActionKind.DANGER -> ActionColors(Tokens.redText, Tokens.tintRed, Tokens.warnBorder) + } + +/** + * One action inside a [RowActions] lane: a small outlined chip in its [kind]'s colours — the + * toolbar's buttons at a size a hover lane has room for. Callers order a lane by kind, in the enum's + * order, and put the [RowActionKind.DANGER] ones in a [DangerActions] group. */ @Composable -fun RowAction(label: String, danger: Boolean = false, onClick: () -> Unit) { - Txt( - label, 11.5.sp, if (danger) Tokens.redText else Tokens.accent, FontWeight.Medium, - modifier = Modifier.pointerHoverIcon(PointerIcon.Hand).onTap(onClick), - ) +fun RowAction(label: String, kind: RowActionKind = RowActionKind.VIEW, onClick: () -> Unit) { + val click = Modifier.pointerHoverIcon(PointerIcon.Hand).onTap(onClick) + val c = kind.colors + val shape = RoundedCornerShape(5.dp) + Box( + click.clip(shape).background(c.fill, shape).border(1.dp, c.border, shape) + .padding(horizontal = 7.dp, vertical = 1.dp), + ) { Txt(label, 11.5.sp, c.text, FontWeight.Medium) } } /** - * [CopyPill]'s behaviour — clipboard, then "✓ Copied" in place for a moment — as a [RowAction], - * for the lists where a bordered pill on every row was the problem. The pill form stays for the - * places it appears alone, like the diff header. + * A lane's destructive actions, kept together and pushed to the lane's far end — out of the path a + * pointer takes along the everyday ones, the way a dialog keeps Delete away from OK. Only in a lane + * laid out full width: in one sized to its content there is no far end to push to, and the lane + * would grow to claim it. When the group doesn't fit, it wraps to a line of its own as one unit. + */ +@Composable +fun androidx.compose.foundation.layout.FlowRowScope.DangerActions(content: @Composable () -> Unit) { + // The group itself takes the weight, rather than a spacer before it: a spacer stays behind on + // the line the group wrapped away from, and the group lands at the start of the next. + androidx.compose.foundation.layout.Row( + Modifier.weight(1f), + horizontalArrangement = androidx.compose.foundation.layout.Arrangement.spacedBy(11.dp, Alignment.End), + verticalAlignment = Alignment.CenterVertically, + ) { content() } +} + +/** + * [CopyPill]'s behaviour — clipboard, then "✓ Copied" in place for a moment — as a + * [RowActionKind.VIEW] [RowAction], for the hover lanes. The pill form stays for the places it + * appears alone, like the diff header. */ @Composable fun CopyAction(value: String, label: String = "Copy") { @@ -511,12 +579,14 @@ fun CopyAction(value: String, label: String = "Copy") { val scope = rememberCoroutineScope() var copied by remember(value) { mutableStateOf(false) } LaunchedEffect(copied) { if (copied) { delay(1200.milliseconds); copied = false } } - Txt( - if (copied) "✓ Copied" else label, 11.5.sp, - if (copied) Tokens.addFg else Tokens.accent, FontWeight.Medium, - modifier = Modifier.pointerHoverIcon(PointerIcon.Hand) - .onTap { scope.launch { clipboard.setText(value); copied = true } }, - ) + val c = RowActionKind.VIEW.colors + val shape = RoundedCornerShape(5.dp) + Box( + Modifier.pointerHoverIcon(PointerIcon.Hand) + .onTap { scope.launch { clipboard.setText(value); copied = true } } + .clip(shape).background(c.fill, shape).border(1.dp, c.border, shape) + .padding(horizontal = 7.dp, vertical = 1.dp), + ) { Txt(if (copied) "✓ Copied" else label, 11.5.sp, if (copied) Tokens.addFg else c.text, FontWeight.Medium) } } /** @@ -677,6 +747,25 @@ fun GearIcon(color: Color, size: Dp = 13.dp, modifier: Modifier = Modifier) { } } +/** + * A pushpin: round head, needle below. Drawn for the reason [GearIcon] is — U+1F4CC and U+1F4CD are + * emoji, and come out in the emoji font's colours rather than [color]. + */ +@Composable +fun PinIcon(color: Color, filled: Boolean, size: Dp = 12.dp, modifier: Modifier = Modifier) { + Canvas(modifier.size(size)) { + val w = this.size.minDimension + val head = w * 0.27f + val headCenter = Offset(center.x, head + w * 0.06f) + drawLine(color, Offset(center.x, headCenter.y), Offset(center.x, w), strokeWidth = w * 0.12f) + if (filled) drawCircle(color, radius = head, center = headCenter) + else { + drawCircle(Tokens.surface, radius = head, center = headCenter) + drawCircle(color, radius = head, center = headCenter, style = Stroke(width = w * 0.12f)) + } + } +} + /** A small filled circle of the given diameter. */ @Composable fun StatusDot(color: Color, diameter: Int, modifier: Modifier = Modifier) { diff --git a/src/main/kotlin/com/gitvantage/ui/DetailPanel.kt b/src/main/kotlin/com/gitvantage/ui/DetailPanel.kt index 331c958..c35bb11 100644 --- a/src/main/kotlin/com/gitvantage/ui/DetailPanel.kt +++ b/src/main/kotlin/com/gitvantage/ui/DetailPanel.kt @@ -6,9 +6,6 @@ package com.gitvantage.ui import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.gestures.detectHorizontalDragGestures -import androidx.compose.foundation.hoverable -import androidx.compose.foundation.interaction.MutableInteractionSource -import androidx.compose.foundation.interaction.collectIsHoveredAsState import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -33,7 +30,6 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip -import androidx.compose.ui.draw.shadow import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.graphics.Color @@ -47,7 +43,6 @@ import androidx.compose.ui.input.key.type import androidx.compose.ui.input.pointer.PointerIcon import androidx.compose.ui.input.pointer.pointerHoverIcon import androidx.compose.ui.input.pointer.pointerInput -import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.text.SpanStyle import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.buildAnnotatedString @@ -57,18 +52,15 @@ import androidx.compose.ui.text.input.TransformedText import androidx.compose.ui.text.input.VisualTransformation import androidx.compose.ui.text.withStyle import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.sp -import androidx.compose.ui.window.Popup as WindowPopup -import androidx.compose.ui.window.PopupProperties import com.gitvantage.app.AppState import com.gitvantage.app.GitHub +import com.gitvantage.app.Launcher import com.gitvantage.app.MonoFont import com.gitvantage.app.Popup import com.gitvantage.app.RepoView import com.gitvantage.app.SettingsSection import com.gitvantage.app.Tokens -import com.gitvantage.app.UiFont import com.gitvantage.git.model.Branch import com.gitvantage.git.model.RemoteBranch import com.gitvantage.git.model.groupByRemote @@ -267,7 +259,8 @@ private fun ActionRow(state: AppState, rv: RepoView) { ActionButton("Commit…", Tokens.onAccent, Tokens.accentFill, null, disabled = !repo.isGitRepo) { state.popup = Popup.Commit(repo.id) } - ActionButton("Push", Tokens.text2, Tokens.surface, Tokens.borderD8, disabled = !repo.hasRemote) { + val git = RowActionKind.GIT.colors + ActionButton("Push", git.text, git.fill, git.border, disabled = !repo.hasRemote) { state.popup = Popup.Confirm( "Push ${repo.name}?", if (repo.upstream != null) "Runs git push to ${repo.pushTarget ?: repo.upstream}." @@ -275,12 +268,12 @@ private fun ActionRow(state: AppState, rv: RepoView) { "Push", danger = false, ) { state.push(listOf(repo.id)) } } - ActionButton("Fetch", Tokens.text2, Tokens.surface, Tokens.borderD8, disabled = !repo.hasRemote) { + ActionButton("Fetch", git.text, git.fill, git.border, disabled = !repo.hasRemote) { state.fetchRepo(repo.id) } // Fast-forward is only possible when strictly behind upstream (no local commits). val canFf = repo.upstream != null && repo.behind > 0 && repo.ahead == 0 - ActionButton("Fast Forward", Tokens.text2, Tokens.surface, Tokens.borderD8, disabled = !canFf) { + ActionButton("Fast Forward", git.text, git.fill, git.border, disabled = !canFf) { state.popup = Popup.Confirm( "Fast-forward ${repo.name}?", "Advances “${repo.branch}” to ${repo.upstream} (${repo.behind} commit${if (repo.behind == 1) "" else "s"}). No local commits are lost.", @@ -288,23 +281,19 @@ private fun ActionRow(state: AppState, rv: RepoView) { ) { state.fastForward(repo.id) } } // The read-only half travels as one unit, so it wraps to the next line together rather than - // leaving a divider stranded at the end of the buttons. Centred against the buttons, which - // are twice as tall as a bare text link. + // leaving a divider stranded at the end of the buttons. Buttons like the git ones, but grey: + // looking costs nothing, so it shouldn't pull the eye the way Push does. Row( Modifier.height(33.dp), verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(10.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), ) { Box(Modifier.width(1.dp).height(22.dp).background(Tokens.borderDc)) + val view = RowActionKind.VIEW.colors val hasChanges = repo.staged + repo.unstaged + repo.untracked > 0 - if (hasChanges) FlatAction("Diff") { state.openDiff(repo.id) } - if (repo.isGitRepo) FlatAction("Log") { state.openRepoLog(repo.id) } - // Out here rather than inside the menu, unlike the other launchers. A terminal in the - // repo is the one thing reached often enough to be worth a click of its own — it's how - // you do whatever this pane doesn't cover — and burying it behind a menu made the pane's - // most-used escape hatch its least visible control. - FlatAction("Terminal") { state.openTerminal(repo.id) } - OpenInMenu(state, repo) + if (hasChanges) ActionButton("Diff", view.text, view.fill, view.border) { state.openDiff(repo.id) } + if (repo.isGitRepo) ActionButton("Log", view.text, view.fill, view.border) { state.openRepoLog(repo.id) } + LauncherControl(state, repo.id, repo, toolbar = true) } // Snooze is its own group: it neither changes the repository nor shows you anything about it // — it changes what the repo is allowed to tell *you*. That's a third kind of action, so it @@ -316,93 +305,88 @@ private fun ActionRow(state: AppState, rv: RepoView) { ) { Box(Modifier.width(1.dp).height(22.dp).background(Tokens.borderDc)) // Carries how long it's silenced for, the way the list rows' chip does — so the button - // reports the state as well as offering to change it. No "Resume now" beside it: the - // snooze banner a few pixels below already has one, and two of them one line apart read - // as two different actions. - ActionButton( - if (rv.snoozed) "☾ ${rv.repo.snoozedFor ?: "on"} ▾" else "☾ Snooze ▾", - Tokens.snoozeBtnText, Tokens.snoozeBtnBg, Tokens.snoozeBtnBorder, - ) { state.popup = Popup.Snooze(setOf(repo.id)) } + // reports the state as well as offering to change it. Resume lives in its menu and in the + // snooze banner a few pixels below, not as a third button beside it. + SnoozeControl(state, setOf(repo.id), SplitSize.TOOLBAR, if (rv.snoozed) rv.repo.snoozedFor ?: "on" else null) } } } /** - * "Open in ▾" — the old OPEN IN bar as a menu. + * "Terminal ▾" — the launchers, as one split control: the label runs the pinned [Launcher], the ▾ + * opens all of them. + * + * It was two things before, a Terminal link beside an "Open in ▾" menu, because a terminal is + * reached often enough to deserve a click of its own and burying it made the pane's most-used + * escape hatch its least visible control. But "most-used" is only true for people who live in a + * terminal, and two controls for one job read as two jobs — a worktree's Folder went unfound for + * exactly that reason. Now the menu has every launcher, and whichever one you use most is a pin + * away from being the one click. * - * That bar carried up to eight bordered buttons across two lines, permanently, for a set of actions - * whose entire effect is to start another program. It was the widest thing in the pane and the least - * consequential. Collapsed, it costs one click and gives the row back to the actions that change - * something. + * The same control in the toolbar and on each worktree card, for the folder at [path]; [repo] adds + * the forge links, which only the repo itself has. One bordered [RowActionKind.VIEW] button split + * by a hairline, at the toolbar's size when [toolbar] and a lane chip's otherwise. */ @Composable -private fun OpenInMenu(state: AppState, repo: Repo) { - var open by remember(repo.id) { mutableStateOf(false) } - val below = with(LocalDensity.current) { 26.dp.roundToPx() } - fun pick(act: () -> Unit): () -> Unit = { open = false; act() } - Box { - FlatAction("Open in ▾") { open = !open } - if (open) { - WindowPopup( - alignment = Alignment.TopStart, - offset = IntOffset(0, below), - onDismissRequest = { open = false }, - properties = PopupProperties(focusable = true), - ) { - val shape = RoundedCornerShape(10.dp) - Column( - Modifier.width(210.dp).shadow(10.dp, shape).clip(shape) - .background(Tokens.surface, shape).border(1.dp, Tokens.borderDc, shape) - .dismissOnEscape { open = false } - .padding(vertical = 5.dp), - ) { - // No Terminal here — it graduated to the action row above. - OpenInItem("GitButler", UiFont, pick { state.openGitButler(repo.id) }) - OpenInItem("Git Gui", UiFont, pick { state.openGitGui(repo.id) }) - OpenInItem("IDE", UiFont, pick { state.openIde(repo.id) }) - OpenInItem("Folder", UiFont, pick { state.openFolder(repo.id) }) - if (repo.isGitHub && repo.webBase != null) { - MenuDivider() - OpenInItem("Issues", UiFont, pick { state.openUrl("${repo.webBase}/issues") }) - OpenInItem("Pull Requests", UiFont, pick { state.openUrl("${repo.webBase}/pulls") }) - if (repo.hasWorkflows) { - OpenInItem("Actions", UiFont, pick { state.openUrl("${repo.webBase}/actions") }) - } - } - // Where the need arises. Which terminal or editor these entries open is exactly - // the question this menu provokes, and a settings dialog reachable only from a - // toolbar button on the other side of the window is one nobody thinks to try. - MenuDivider() - OpenInItem("Configure…", UiFont, pick { - state.popup = Popup.Settings(SettingsSection.TOOLS) - }) - } +internal fun LauncherControl(state: AppState, path: String, repo: Repo?, toolbar: Boolean = false) { + val pinned = state.pinnedLauncher + SplitMenuButton( + pinned.label, RowActionKind.VIEW, if (toolbar) SplitSize.TOOLBAR else SplitSize.LANE, + "Open in… — and choose which one “${pinned.label}” opens", + onMain = { state.launch(pinned, path) }, + ) { close -> + fun pick(act: () -> Unit): () -> Unit = { close(); act() } + Launcher.entries.forEach { l -> + MenuItem(l.label, pick { state.launch(l, path) }, pinned = l == pinned) { state.pinLauncher(l) } + } + if (repo != null && repo.isGitHub && repo.webBase != null) { + MenuDivider() + MenuItem("Issues", pick { state.openUrl("${repo.webBase}/issues") }) + MenuItem("Pull Requests", pick { state.openUrl("${repo.webBase}/pulls") }) + if (repo.hasWorkflows) { + MenuItem("Actions", pick { state.openUrl("${repo.webBase}/actions") }) } } + // Where the need arises. Which terminal or editor these entries open is exactly the question + // this menu provokes, and a settings dialog reachable only from a toolbar button on the other + // side of the window is one nobody thinks to try. + MenuDivider() + MenuItem("Configure…", pick { state.popup = Popup.Settings(SettingsSection.TOOLS) }) } } -/** A hairline between groups of menu items. */ +/** + * "☾ Snooze 1 day ▾" — one click silences [ids] for the pinned duration, the ▾ offers the others. + * + * It was a dialog, which is a lot of ceremony for a list of presets that each apply on one click + * with nothing to confirm. Snoozing is something you do in passing, on the way to something else, + * so it should cost what it costs to pass. + * + * Already snoozed, the label reports how long is left and opens the menu rather than acting: the + * likely next move is to resume or change the length, and a one-click re-snooze from a label that + * says "3h" would be a surprise. Several [ids] (the bulk bar) always act, since they needn't agree. + */ @Composable -private fun MenuDivider() { - Box( - Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 4.dp) - .height(1.dp).background(Tokens.borderEd), - ) +internal fun SnoozeControl(state: AppState, ids: Set, size: SplitSize, snoozedFor: String?) { + val pinned = state.pinnedSnooze + SplitMenuButton( + if (snoozedFor != null) "☾ $snoozedFor" else "☾ Snooze ${snoozeShort(pinned.first)}", + RowActionKind.APP, size, + "Snooze for another length — and choose the one-click default", + onMain = if (snoozedFor != null) null else ({ state.snoozeFor(ids, pinned.second) }), + ) { close -> + Meta.SNOOZE_PRESETS.forEach { p -> + MenuItem(p.first, { close(); state.snoozeFor(ids, p.second) }, pinned = p == pinned) { + state.pinSnooze(p.first) + } + } + MenuDivider() + MenuItem("Resume now", { close(); state.setSnoozeUntil(ids, null) }, color = Tokens.accent) + } } -@Composable -private fun OpenInItem(label: String, font: androidx.compose.ui.text.font.FontFamily, onClick: () -> Unit) { - val source = remember { MutableInteractionSource() } - val hovered by source.collectIsHoveredAsState() - Box( - Modifier.fillMaxWidth() - .background(if (hovered) Tokens.rowHoverBg else Color.Transparent) - .hoverable(source) - .pointerHoverIcon(PointerIcon.Hand).onTap(onClick) - .padding(horizontal = 14.dp, vertical = 7.dp), - ) { Txt(label, 12.sp, Tokens.text2, FontWeight.Medium, font = font) } -} +/** A preset as the button's label says it: "Snooze 1 day", but "Snooze until resumed". */ +private fun snoozeShort(preset: String) = if (preset == "Until I resume") "until resumed" else preset /** * Zone 5: what's changed in this checkout. @@ -1017,14 +1001,14 @@ private fun StashSection(state: AppState, id: String, stashes: List) { } RowActions(hovered) { RowAction("Diff") { state.openStashDiff(id, st.label) } - RowAction("Apply") { + RowAction("Apply", RowActionKind.GIT) { state.popup = Popup.Confirm( "Apply ${st.label}?", "Restores this stash's changes into the working tree (the stash is kept).", "Apply", danger = false, ) { state.stashApply(id, st.label) } } - RowAction("Drop", danger = true) { + RowAction("Drop", RowActionKind.DANGER) { state.popup = Popup.Confirm( "Drop ${st.label}?", "Permanently deletes this stash.", "Drop", danger = true, ) { state.stashDrop(id, st.label) } @@ -1096,24 +1080,30 @@ private fun SubmoduleRow(state: AppState, id: String, s: Submodule) { 11.sp, Tokens.amber, FontWeight.SemiBold, modifier = Modifier.padding(top = 3.dp), ) } - // Actions (wrap as the pane narrows) + // Actions (wrap as the pane narrows), in [RowActionKind] order: look, change, track, destroy. RowActions(hovered, Modifier.fillMaxWidth().padding(top = 4.dp)) { + // Diff against whatever branch this submodule tracks (main, or a configured + // submodule..branch like develop) — not necessarily mainline. + if (s.initialized && s.behind > 0 && s.remoteRef != null) { + val shortRef = s.remoteRef.substringAfterLast('/') + RowAction("Diff $shortRef") { state.openSubmoduleDiff(id, s) } + RowAction("Log $shortRef") { state.openSubmoduleLog(id, s) } + } + if (tracked) RowAction("Goto Repo") { + state.trackedRepoAt(subFullPath)?.let { state.selectedId = it } + } if (!s.initialized) { - if (!busy) RowAction("Init") { state.initSubmodule(id, s.path) } - } else { - if (!busy) RowAction("Fetch") { state.fetchSubmodule(id, s.path) } + if (!busy) RowAction("Init", RowActionKind.GIT) { state.initSubmodule(id, s.path) } + } else if (!busy) { + RowAction("Fetch", RowActionKind.GIT) { state.fetchSubmodule(id, s.path) } // The checkout wandered off the recorded commit — put it back. Safe without a // confirmation: git refuses rather than overwriting uncommitted work inside. - if (s.moved && !busy) RowAction("Move to parent hash") { state.moveSubmoduleToParentPointer(id, s.path) } - if (s.behind > 0 && !busy) RowAction("Update") { state.updateSubmodulePointer(id, s.path) } - // Diff against whatever branch this submodule tracks (main, or a configured - // submodule..branch like develop) — not necessarily mainline. - if (s.behind > 0 && s.remoteRef != null) { - val shortRef = s.remoteRef.substringAfterLast('/') - RowAction("Diff $shortRef") { state.openSubmoduleDiff(id, s) } - RowAction("Log $shortRef") { state.openSubmoduleLog(id, s) } - } - if (!busy) RowAction("Deinit", danger = true) { + if (s.moved) RowAction("Move to parent hash", RowActionKind.GIT) { state.moveSubmoduleToParentPointer(id, s.path) } + if (s.behind > 0) RowAction("Update", RowActionKind.GIT) { state.updateSubmodulePointer(id, s.path) } + } + if (!tracked && s.initialized && !busy) RowAction("Add Repo", RowActionKind.APP) { state.trackSubmodule(id, s) } + if (s.initialized && !busy) DangerActions { + RowAction("Deinit", RowActionKind.DANGER) { state.popup = Popup.Confirm( "Deinitialize ${s.path}?", "Removes the submodule's working tree (its files). You can re-init later; the pointer in the parent is untouched.", @@ -1121,10 +1111,6 @@ private fun SubmoduleRow(state: AppState, id: String, s: Submodule) { ) { state.deinitSubmodule(id, s.path) } } } - if (tracked) RowAction("Goto Repo") { - state.trackedRepoAt(subFullPath)?.let { state.selectedId = it } - } - else if (s.initialized && !busy) RowAction("Add Repo") { state.trackSubmodule(id, s) } } } } @@ -1336,6 +1322,15 @@ private fun BranchRow( RowAction("Diff") { state.openBranchDiff(id, b.name, b.name) } RowAction("Log") { state.openBranchLog(id, b.name, b.name) } } + // The branch is already checked out somewhere, so the useful move is to go there — + // the stand-in for Switch, but a link, so it sits with the links. Only when that + // worktree is tracked: offering to open an untracked one is the "+ Add Repo" flow, + // and it belongs on the worktree list, not here. + if (b.inOtherWorktree) { + b.worktreePath?.let { p -> + state.trackedRepoAt(p)?.let { t -> RowAction("Goto Repo") { state.selectedId = t } } + } + } // Between the read-only actions and the ones that move you: this changes the // remote, not where you're standing or what's on disk. if (canSync) { @@ -1347,7 +1342,7 @@ private fun BranchRow( if (b.isCurrent) "It's checked out here, so your working tree moves with it." else "It isn't checked out, so your working tree isn't touched.", ) { - RowAction(if (pushing) "Syncing…" else "Sync") { + RowAction(if (pushing) "Syncing…" else "Sync", RowActionKind.GIT) { if (!pushing) state.syncBranch(id, b) } } @@ -1367,7 +1362,7 @@ private fun BranchRow( "to ${b.upstream}. Your working tree isn't touched." }, ) { - RowAction(if (pushing) "Pushing…" else pushLabel) { + RowAction(if (pushing) "Pushing…" else pushLabel, RowActionKind.GIT) { if (!pushing) state.pushBranch(id, b) } } @@ -1380,7 +1375,7 @@ private fun BranchRow( val remote = c.substringBefore('/') val pushesTo = (b.pushRef ?: b.upstream)?.substringBefore('/') // "Track", not "Pull": it only sets the upstream; nothing is fetched or merged. - RowAction("Track remote $remote") { + RowAction("Track remote $remote", RowActionKind.GIT) { state.popup = Popup.Confirm( "Track “${b.name}” on $remote?", // Short enough for the dialog's three lines; the config it writes is @@ -1392,7 +1387,7 @@ private fun BranchRow( } } } - if (canSwitch) RowAction("Switch") { + if (canSwitch) RowAction("Switch", RowActionKind.GIT) { if (clean) state.switchBranch(id, b.name) else state.popup = Popup.Confirm( "Switch to ${b.name}?", @@ -1403,21 +1398,13 @@ private fun BranchRow( // Beside Switch because it's the other way to get onto the branch — one that leaves // this checkout, and anything uncommitted in it, exactly where it is. Off for the same // branches Switch is: git holds a branch in one tree at a time. - if (!b.isCurrent && !b.inOtherWorktree) RowAction("Worktree") { + if (!b.isCurrent && !b.inOtherWorktree) RowAction("Worktree", RowActionKind.GIT) { state.openNewWorktree(id, WorktreeSource.LocalBranch(b.name)) } - // Where "Switch" would have been: the branch is already checked out somewhere, so the - // useful move is to go there. Only when that worktree is tracked — offering to open an - // untracked one is the "+ Add Repo" flow, and it belongs on the worktree list, not here. - if (b.inOtherWorktree) { - b.worktreePath?.let { p -> - state.trackedRepoAt(p)?.let { t -> RowAction("Goto Repo") { state.selectedId = t } } - } - } // Joined the other actions rather than keeping its own ✕ up on line 1: a delete // glyph standing permanently beside every branch name is both the loudest thing in // the list and the one thing you least want a stray click to find. - if (canDelete) RowAction("Delete", danger = true) { + if (canDelete) RowAction("Delete", RowActionKind.DANGER) { // Mirror `git branch -d/-D`: a merged branch deletes with no prompt (nothing is // lost). We use -D because "merged" is measured against mainline — `git branch -d` // would refuse when you're on a different branch, even though the commits are safely @@ -1440,7 +1427,7 @@ private fun BranchRow( "already in mainline, so nothing is lost — and if someone has pushed to " + "it since your last fetch, the remote refuses and both stay.", ) { - RowAction(if (deletingUpstream) "Deleting…" else "Delete both", danger = true) { + RowAction(if (deletingUpstream) "Deleting…" else "Delete both", RowActionKind.DANGER) { if (!deletingUpstream) state.deleteBranchWithUpstream(id, b.name, deletableUpstream) } } @@ -1638,7 +1625,7 @@ private fun RemoteBranchRow( RowAction("Diff") { state.openBranchDiff(id, rb.name, rb.name) } RowAction("Log") { state.openBranchLog(id, rb.name, rb.name) } } - if (canSwitch) RowAction(if (rb.hasLocal) "Switch" else "Checkout") { + if (canSwitch) RowAction(if (rb.hasLocal) "Switch" else "Checkout", RowActionKind.GIT) { if (clean) state.checkoutRemoteBranch(id, rb) else state.popup = Popup.Confirm( if (rb.hasLocal) "Switch to ${rb.shortName}?" else "Check out ${rb.shortName}?", @@ -1650,7 +1637,7 @@ private fun RemoteBranchRow( // track the same remote would be a surprise. Off when that local branch is already // checked out somewhere, which git would refuse. val local = if (rb.hasLocal) state.branches.find { it.name == rb.shortName } else null - if (local == null || (!local.isCurrent && !local.inOtherWorktree)) RowAction("Worktree") { + if (local == null || (!local.isCurrent && !local.inOtherWorktree)) RowAction("Worktree", RowActionKind.GIT) { state.openNewWorktree( id, if (rb.hasLocal) WorktreeSource.LocalBranch(rb.shortName) else WorktreeSource.RemoteBranch(rb.name), @@ -1671,7 +1658,7 @@ private fun RemoteBranchRow( "mainline, so its commits survive only where they're still checked out." }, ) { - RowAction(if (deleting) "Deleting…" else "Delete", danger = true) { + RowAction(if (deleting) "Deleting…" else "Delete", RowActionKind.DANGER) { if (deleting) return@RowAction // Same rule as the local list one section up: merged means the commits // are safe in mainline, so the delete just goes. Unmerged is the case @@ -1976,7 +1963,7 @@ private fun IssueRow(state: AppState, id: String, item: GitHub.Item, clean: Bool "Fetches this PR's branch and checks it out here (`gh pr checkout " + "${item.number}`) — from a fork too, no remote setup needed.", ) { - RowAction("Checkout") { + RowAction("Checkout", RowActionKind.GIT) { if (clean) state.checkoutPr(id, item.number) else state.popup = Popup.Confirm( "Check out PR #${item.number}?", @@ -1986,7 +1973,7 @@ private fun IssueRow(state: AppState, id: String, item: GitHub.Item, clean: Bool } } HoverTip("Checks this PR out into a new folder of its own, leaving this checkout as it is.") { - RowAction("Worktree") { + RowAction("Worktree", RowActionKind.GIT) { state.openNewWorktree(id, WorktreeSource.PullRequest(item.number, item.title)) } } diff --git a/src/main/kotlin/com/gitvantage/ui/LogView.kt b/src/main/kotlin/com/gitvantage/ui/LogView.kt index 6a7cb7c..4cd1c64 100644 --- a/src/main/kotlin/com/gitvantage/ui/LogView.kt +++ b/src/main/kotlin/com/gitvantage/ui/LogView.kt @@ -44,8 +44,6 @@ import androidx.compose.ui.input.key.onPreviewKeyEvent import androidx.compose.ui.input.key.type import androidx.compose.ui.input.pointer.PointerEventPass import androidx.compose.ui.input.pointer.PointerEventType -import androidx.compose.ui.input.pointer.PointerIcon -import androidx.compose.ui.input.pointer.pointerHoverIcon import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp @@ -188,15 +186,15 @@ private fun CommitRow(state: AppState, c: Commit) { Modifier.alpha(if (hovered) 1f else 0f), horizontalArrangement = Arrangement.spacedBy(10.dp), ) { - LogPill("Diff", accent) { state.openCommitDiff(state.logRepoId, c.fullHash, c.shortHash) } + RowAction("Diff") { state.openCommitDiff(state.logRepoId, c.fullHash, c.shortHash) } // Only for a log of a tracked repo: a submodule's or a worktree's log has no parent // entry here for the new tree to appear under. Named after the tag when the commit // carries one, since "v1.4.0" is what you'd call that folder. - if (worktreeable) LogPill("Worktree", accent) { + if (worktreeable) RowAction("Worktree", RowActionKind.GIT) { val tag = c.refs.firstOrNull { it.kind == RefKind.TAG }?.label state.openNewWorktree(state.logRepoId, WorktreeSource.Commit(c.fullHash, tag ?: c.shortHash)) } - if (webBase != null) LogPill("↗ GitHub", accent) { state.openUrl("$webBase/commit/${c.fullHash}") } + if (webBase != null) RowAction("↗ GitHub") { state.openUrl("$webBase/commit/${c.fullHash}") } } } // Merge commits are where history actually branches, and a flat list hides that entirely. @@ -222,7 +220,7 @@ private fun CommitRow(state: AppState, c: Commit) { "What ${c.shortHash} changed relative to parent ${i + 1} (${parent.shortHash}) — " + "for a merge, that's the work the other side brought in.", ) { - LogPill("p${i + 1} ${parent.shortHash}", accent) { + RowAction("p${i + 1} ${parent.shortHash}") { state.openRangeDiff( state.logRepoId, parent.fullHash, c.fullHash, "Merge ${c.shortHash} vs parent ${i + 1} (${parent.shortHash})", @@ -286,16 +284,6 @@ private fun RefChipBody(label: String, fg: Color, bg: Color, shape: Shape) { } } -@Composable -private fun LogPill(label: String, accent: Color, onClick: () -> Unit) { - Box( - Modifier.clip(RoundedCornerShape(6.dp)).background(Tokens.tintBlue) - .border(1.dp, Tokens.accentBorder, RoundedCornerShape(6.dp)) - .pointerHoverIcon(PointerIcon.Hand).onTap(onClick) - .padding(horizontal = 8.dp, vertical = 2.dp), - ) { Txt(label, 10.5.sp, accent, FontWeight.SemiBold) } -} - @Composable private fun CenteredLogMessage(text: String) { Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { Txt(text, 13.sp, Tokens.muted2) } diff --git a/src/main/kotlin/com/gitvantage/ui/Popups.kt b/src/main/kotlin/com/gitvantage/ui/Popups.kt index 92347f2..f81fccb 100644 --- a/src/main/kotlin/com/gitvantage/ui/Popups.kt +++ b/src/main/kotlin/com/gitvantage/ui/Popups.kt @@ -82,7 +82,6 @@ fun PopupHost(state: AppState, popup: Popup) { when (popup) { is Popup.Tag -> TagPopup(state, popup.ids) is Popup.Untag -> UntagPopup(state, popup.ids) - is Popup.Snooze -> SnoozePopup(state, popup.ids) is Popup.SnoozeWorktree -> SnoozeWorktreePopup(state, popup) is Popup.Remind -> RemindPopup(state, popup.ids, popup.text) is Popup.Note -> NotePopup(state, popup.id) @@ -157,27 +156,6 @@ private fun UntagPopup(state: AppState, ids: Set) { } } -@Composable -private fun SnoozePopup(state: AppState, ids: Set) { - Modal({ state.popup = null }) { - ModalHeader("Snooze ${ids.size} ${noun(ids.size)}", "Silence attention flags (dirty/unpushed/stale) for a while.") - Column(Modifier.padding(vertical = 6.dp)) { - Meta.SNOOZE_PRESETS.forEach { (label, ms) -> - MenuRow(label) { - val until = ms?.let { System.currentTimeMillis() + it } ?: Meta.SNOOZE_FOREVER - state.setSnoozeUntil(ids, until) - state.popup = null - } - } - Box(Modifier.fillMaxWidth().padding(horizontal = 18.dp, vertical = 4.dp).height(1.dp).background(Tokens.borderEd)) - MenuRow("Resume now (clear snooze)", accent = true) { - state.setSnoozeUntil(ids, null) - state.popup = null - } - } - } -} - /** * The working-note editor. * @@ -388,6 +366,18 @@ private fun ToolsSection(state: AppState, dismiss: () -> Unit) { Modifier.padding(horizontal = 18.dp).padding(bottom = 10.dp), verticalArrangement = Arrangement.spacedBy(9.dp), ) { + // The same choice as the pin in the "▾" menu, for anyone who never hovers a menu item. Up + // here because it decides which of the commands below a click actually runs. + Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { + Txt("Main launcher", 12.5.sp, Tokens.text, FontWeight.SemiBold) + Segmented( + com.gitvantage.app.Launcher.entries.map { it.id to it.label }, + state.pinnedLauncher.id, + { state.pinLauncher(com.gitvantage.app.Launcher.fromId(it)) }, + fontSize = 12.sp, + ) + Txt("What the launcher button opens, in the toolbar and on each worktree.", 11.sp, Tokens.muted2) + } ExternalTools.Tool.entries.forEach { tool -> Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { Row( diff --git a/src/main/kotlin/com/gitvantage/ui/SplitMenu.kt b/src/main/kotlin/com/gitvantage/ui/SplitMenu.kt new file mode 100644 index 0000000..cb1634e --- /dev/null +++ b/src/main/kotlin/com/gitvantage/ui/SplitMenu.kt @@ -0,0 +1,148 @@ +// SPDX-FileCopyrightText: 2026 Raman Gupta +// SPDX-License-Identifier: GPL-3.0-or-later + +package com.gitvantage.ui + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.hoverable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.interaction.collectIsHoveredAsState +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.shadow +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.input.pointer.PointerIcon +import androidx.compose.ui.input.pointer.pointerHoverIcon +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.compose.ui.window.Popup +import androidx.compose.ui.window.PopupProperties +import com.gitvantage.app.Tokens +import com.gitvantage.app.UiFont + +/** + * One button split by a hairline: the label does the common thing in one click, the ▾ opens the + * menu of everything else. "Open in" and Snooze both have this shape — a short list of choices of + * which one is used far more than the rest — and the menu is where the user pins which one that is. + * + * [onMain] null makes the label open the menu too, for a state in which the one-click action would + * be the wrong default (a repo that is already snoozed). + */ +@Composable +fun SplitMenuButton( + label: String, + kind: RowActionKind, + size: SplitSize, + menuTip: String, + onMain: (() -> Unit)?, + menu: @Composable ColumnScope.(close: () -> Unit) -> Unit, +) { + var open by remember { mutableStateOf(false) } + val belowPx = with(LocalDensity.current) { (size.height + 5.dp).roundToPx() } + val c = kind.colors + val shape = RoundedCornerShape(size.radius) + + @Composable + fun Half(text: String, onClick: () -> Unit) = Box( + Modifier.fillMaxHeight().pointerHoverIcon(PointerIcon.Hand).onTap(onClick) + .padding(horizontal = size.padH), + contentAlignment = Alignment.Center, + ) { Txt(text, size.font, c.text, size.weight) } + + // The popup hangs from the whole control rather than from the ▾, so it doesn't move when the + // label changes length under it — which pinning does, with the menu still open. + Box { + Row( + Modifier.height(size.height).clip(shape).background(c.fill, shape).border(1.dp, c.border, shape), + verticalAlignment = Alignment.CenterVertically, + ) { + Half(label, onMain ?: { open = !open }) + Box(Modifier.width(1.dp).fillMaxHeight().background(c.border)) + HoverTip(menuTip) { Half("▾") { open = !open } } + } + if (open) { + Popup( + alignment = Alignment.TopStart, + offset = IntOffset(0, belowPx), + onDismissRequest = { open = false }, + properties = PopupProperties(focusable = true), + ) { + val menuShape = RoundedCornerShape(10.dp) + Column( + Modifier.width(210.dp).shadow(10.dp, menuShape).clip(menuShape) + .background(Tokens.surface, menuShape).border(1.dp, Tokens.borderDc, menuShape) + .dismissOnEscape { open = false } + .padding(vertical = 5.dp), + ) { menu { open = false } } + } + } + } +} + +/** A hairline between groups of menu items. */ +@Composable +fun MenuDivider() { + Box( + Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 4.dp) + .height(1.dp).background(Tokens.borderEd), + ) +} + +/** + * One menu entry. With [onPin], it can be pinned as the [SplitMenuButton]'s one click: the pinned + * one shows a filled pin always, the others an outline pin only while hovered, so the menu reads as + * a plain list until you go looking. The pin is its own click target — choosing an entry and pinning + * it are different acts, and a click on the name should never quietly change what the button does. + * Pinning leaves the menu open: the button's label changes in place, which is the confirmation. + */ +@Composable +fun MenuItem( + label: String, + onClick: () -> Unit, + pinned: Boolean = false, + color: Color = Tokens.text2, + onPin: (() -> Unit)? = null, +) { + val source = remember { MutableInteractionSource() } + val hovered by source.collectIsHoveredAsState() + Row( + Modifier.fillMaxWidth() + .background(if (hovered) Tokens.rowHoverBg else Color.Transparent) + .hoverable(source) + .pointerHoverIcon(PointerIcon.Hand).onTap(onClick) + .padding(start = 14.dp, end = 8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Txt( + label, 12.sp, color, if (pinned) FontWeight.Bold else FontWeight.Medium, font = UiFont, + modifier = Modifier.weight(1f).padding(vertical = 7.dp), + ) + if (onPin != null && (pinned || hovered)) { + HoverTip(if (pinned) "Pinned — the button's one click" else "Pin — make this the button's one click") { + Box(Modifier.onTap(onPin).padding(6.dp)) { + PinIcon(if (pinned) Tokens.accent else Tokens.muted, filled = pinned) + } + } + } + } +} diff --git a/src/main/kotlin/com/gitvantage/ui/Worktrees.kt b/src/main/kotlin/com/gitvantage/ui/Worktrees.kt index 7e31052..3ead2cd 100644 --- a/src/main/kotlin/com/gitvantage/ui/Worktrees.kt +++ b/src/main/kotlin/com/gitvantage/ui/Worktrees.kt @@ -292,9 +292,12 @@ private fun WorktreeCard(state: AppState, rv: RepoView, wtv: WorktreeView) { // whether the expand came from the table, a card strip, or a click on the card itself. val bring = remember { BringIntoViewRequester() } LaunchedEffect(expanded) { if (expanded) runCatching { bring.bringIntoView() } } + // Outlined in amber while it holds uncommitted work, the colour of its "N uncommitted" badge. The + // badge says it, but a badge is something you read; the frame is something you see from the + // other end of the list, which is where you are when you're deciding which tree to go back to. Column( Modifier.fillMaxWidth().bringIntoViewRequester(bring) - .clip(shape).border(1.dp, Tokens.borderE6, shape), + .clip(shape).border(1.dp, if (wt.dirtyCount > 0) Tokens.amber else Tokens.borderE6, shape), ) { // Header — the whole strip toggles, so the chevron is an indicator rather than the target. Column( @@ -356,19 +359,22 @@ private fun WorktreeCard(state: AppState, rv: RepoView, wtv: WorktreeView) { Modifier.fillMaxWidth().padding(start = 10.dp, end = 10.dp, top = 2.dp, bottom = 8.dp), horizontalArrangement = Arrangement.spacedBy(11.dp), verticalArrangement = Arrangement.spacedBy(4.dp), + itemVerticalAlignment = Alignment.CenterVertically, ) { + // The pane's action row in miniature, in [RowActionKind] order — Log and the launcher + // control are the same two it ends with, so a worktree's folder is found where the + // repo's is. if (!wt.missing) { + if (wt.dirtyCount > 0) RowAction("Open Diff") { state.openWorktreeDiff(wt.path) } + RowAction("Log") { state.openWorktreeLog(wt.path) } + LauncherControl(state, wt.path, repo = null) if (wt.dirtyCount > 0) { - RowAction("Open Diff") { state.openWorktreeDiff(wt.path) } - RowAction("Commit…") { state.popup = AppPopup.CommitWorktree(rv.id, wt.path, wtv.label) } + RowAction("Commit…", RowActionKind.GIT) { state.popup = AppPopup.CommitWorktree(rv.id, wt.path, wtv.label) } } - RowAction("Log") { state.openWorktreeLog(wt.path) } - RowAction("Terminal") { state.openTerminal(wt.path) } - RowAction("Folder") { state.openFolder(wt.path) } } AlertsAnchor(state, rv, wtv) - if (!state.worktreesBusy && !wt.missing) { - RowAction("Remove", danger = true) { confirmRemove(state, rv, wt, alsoBranch = false) } + if (!state.worktreesBusy && !wt.missing) DangerActions { + RowAction("Remove", RowActionKind.DANGER) { confirmRemove(state, rv, wt, alsoBranch = false) } if (canRemoveWithBranch(wt)) { RemoveWithBranchAction(state, rv, wt) } @@ -495,7 +501,7 @@ private fun AlertsAnchor(state: AppState, rv: RepoView, wtv: WorktreeView) { var open by remember(wtv.path) { mutableStateOf(false) } val below = with(LocalDensity.current) { 20.dp.roundToPx() } Box { - RowAction(if (wtv.overridden) "Alerts ▾ ⚙" else "Alerts ▾") { open = !open } + RowAction(if (wtv.overridden) "☾ Alerts ▾ ⚙" else "☾ Alerts ▾", RowActionKind.APP) { open = !open } if (open) { Popup( alignment = Alignment.TopStart, @@ -654,7 +660,7 @@ private fun RemoveWithBranchAction(state: AppState, rv: RepoView, wt: Worktree) "Deletes this folder and the branch “${wt.ownBranch}”." }, ) { - RowAction(if (upstream != null) "Remove + branch + ${upstream.substringBefore('/')}" else "Remove + branch", danger = true) { + RowAction(if (upstream != null) "Remove + branch + ${upstream.substringBefore('/')}" else "Remove + branch", RowActionKind.DANGER) { confirmRemove(state, rv, wt, alsoBranch = true) } } diff --git a/src/test/kotlin/com/gitvantage/model/WorktreeOrderTest.kt b/src/test/kotlin/com/gitvantage/model/WorktreeOrderTest.kt new file mode 100644 index 0000000..8783144 --- /dev/null +++ b/src/test/kotlin/com/gitvantage/model/WorktreeOrderTest.kt @@ -0,0 +1,35 @@ +// SPDX-FileCopyrightText: 2026 Raman Gupta +// SPDX-License-Identifier: GPL-3.0-or-later + +package com.gitvantage.model + +import de.infix.testBalloon.framework.core.testSuite + +/** [inDisplayOrder]: uncommitted work first, then newest commit, ties left in git's path order. */ +val WorktreeDisplayOrder by testSuite { + + fun tree(name: String, dirty: Int = 0, epoch: Long? = null) = Worktree( + path = "/w/$name", head = "abc1234", branch = name, bare = false, + locked = false, lockReason = null, prunable = false, prunableReason = null, + isMain = false, dirtyCount = dirty, lastEpoch = epoch, + ) + + fun names(trees: List) = trees.inDisplayOrder().map { it.branch } + + test("a dirty tree comes before a clean one, however old its last commit") { + assert(names(listOf(tree("clean", epoch = 200), tree("dirty", dirty = 3, epoch = 100))) == listOf("dirty", "clean")) + } + + test("within each group, the most recent commit comes first") { + val trees = listOf( + tree("a", epoch = 100), tree("b", dirty = 1, epoch = 100), + tree("c", epoch = 300), tree("d", dirty = 2, epoch = 300), + ) + assert(names(trees) == listOf("d", "b", "c", "a")) + } + + test("a tree with no commit time sorts last in its group, and ties keep git's order") { + val trees = listOf(tree("gone"), tree("x", epoch = 100), tree("y"), tree("z", epoch = 100)) + assert(names(trees) == listOf("x", "z", "gone", "y")) + } +}