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")) + } +}