Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 38 additions & 2 deletions src/main/kotlin/com/gitvantage/app/AppState.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -77,7 +78,6 @@ sealed interface Popup {
val ids: Set<String>
data class Tag(override val ids: Set<String>) : Popup
data class Untag(override val ids: Set<String>) : Popup
data class Snooze(override val ids: Set<String>) : Popup
data class Remind(override val ids: Set<String>, val text: String, val due: Long?) : Popup

/**
Expand Down Expand Up @@ -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<String, Long?> =
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<String>, 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). */
Expand Down Expand Up @@ -2922,7 +2958,7 @@ class AppState(private val scope: CoroutineScope) {
fun worktreeViews(repoId: String, repoSnoozed: Boolean, trees: List<Worktree>): List<WorktreeView> {
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(
Expand Down
29 changes: 29 additions & 0 deletions src/main/kotlin/com/gitvantage/app/Launcher.kt
Original file line number Diff line number Diff line change
@@ -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
}
}
5 changes: 5 additions & 0 deletions src/main/kotlin/com/gitvantage/model/RegistryEntry.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
13 changes: 13 additions & 0 deletions src/main/kotlin/com/gitvantage/model/Worktrees.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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<Worktree>.inDisplayOrder(): List<Worktree> =
sortedWith(compareByDescending<Worktree> { it.dirtyCount > 0 }.thenByDescending { it.lastEpoch ?: Long.MIN_VALUE })

/**
* One changed file inside a worktree, with the diffstat the inline Changes list shows.
*
Expand Down
21 changes: 14 additions & 7 deletions src/main/kotlin/com/gitvantage/ui/Bars.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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)
}
}

Expand Down
127 changes: 108 additions & 19 deletions src/main/kotlin/com/gitvantage/ui/Components.kt
Original file line number Diff line number Diff line change
Expand Up @@ -479,44 +479,114 @@ 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") {
val clipboard = LocalClipboard.current
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) }
}

/**
Expand Down Expand Up @@ -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) {
Expand Down
Loading
Loading