diff --git a/src/main/kotlin/com/gitvantage/app/GitHub.kt b/src/main/kotlin/com/gitvantage/app/GitHub.kt index 8294a50..50e6d2c 100644 --- a/src/main/kotlin/com/gitvantage/app/GitHub.kt +++ b/src/main/kotlin/com/gitvantage/app/GitHub.kt @@ -669,7 +669,7 @@ object GitHub { val reviewIsNewest = lastReview != null && lastReview.submittedAt.toMillis() >= lastComment?.createdAt.toMillis() val reviewedMine = viewerDidAuthor && reviewIsNewest && !lastReviewBy.isLogin(viewer) && - lastReviewBy.isNotEmpty() && lastReview?.reactionGroups.orEmpty().none { it.viewerHasReacted } + lastReviewBy.isNotEmpty() && lastReview.reactionGroups.orEmpty().none { it.viewerHasReacted } val mentioned = lastComment != null && !acknowledged && mentionsMe(lastComment.bodyText, viewer) // "Someone replied on my own thread and I haven't answered." Only counts when the last diff --git a/src/main/kotlin/com/gitvantage/git/WorktreeOps.kt b/src/main/kotlin/com/gitvantage/git/WorktreeOps.kt index a1d8f9d..87f7c03 100644 --- a/src/main/kotlin/com/gitvantage/git/WorktreeOps.kt +++ b/src/main/kotlin/com/gitvantage/git/WorktreeOps.kt @@ -177,7 +177,7 @@ object WorktreeOps { branchMerged = merged, branchEmpty = contained && !merged, tipBranch = tipBranch, - deletableUpstream = if (merged && own != null) BranchOps.deletableUpstream(dir, own) else null, + deletableUpstream = if (merged) BranchOps.deletableUpstream(dir, own) else null, ) } } diff --git a/src/main/kotlin/com/gitvantage/ui/Components.kt b/src/main/kotlin/com/gitvantage/ui/Components.kt index 47ec3b9..f8e0694 100644 --- a/src/main/kotlin/com/gitvantage/ui/Components.kt +++ b/src/main/kotlin/com/gitvantage/ui/Components.kt @@ -29,6 +29,7 @@ import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -50,8 +51,10 @@ import androidx.compose.ui.input.pointer.isMetaPressed import androidx.compose.ui.input.pointer.isShiftPressed import androidx.compose.ui.input.pointer.pointerHoverIcon import androidx.compose.ui.input.pointer.pointerInput -import androidx.compose.ui.platform.LocalClipboardManager -import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.ExperimentalComposeUiApi +import androidx.compose.ui.platform.ClipEntry +import androidx.compose.ui.platform.Clipboard +import androidx.compose.ui.platform.LocalClipboard import androidx.compose.ui.text.TextRange import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontWeight @@ -65,12 +68,14 @@ import com.gitvantage.app.MonoFont import com.gitvantage.app.Tokens import com.gitvantage.app.UiFont import kotlinx.coroutines.delay +import kotlinx.coroutines.launch import kotlin.time.Duration.Companion.milliseconds import androidx.compose.foundation.Canvas import androidx.compose.ui.graphics.drawscope.Stroke import androidx.compose.ui.unit.Dp import kotlin.math.cos import kotlin.math.sin +import java.awt.datatransfer.StringSelection /** A 1px bottom hairline, like CSS `border-bottom`. */ fun Modifier.drawBottomBorder(color: Color): Modifier = this.drawBehind { @@ -393,6 +398,15 @@ fun InfoTip(text: String, modifier: Modifier = Modifier) { } } +/** + * Put [text] on the system clipboard as plain text. + * + * [Clipboard] only takes a [ClipEntry], and on desktop the one way to build that is around an AWT + * `Transferable` — a constructor Compose still marks experimental, hence the opt-in. + */ +@OptIn(ExperimentalComposeUiApi::class) +suspend fun Clipboard.setText(text: String) = setClipEntry(ClipEntry(StringSelection(text))) + /** * A pill that puts [value] on the clipboard and confirms it in place — the label swaps to * "✓ Copied" for a moment. The confirmation is local rather than a toast because the toast @@ -405,7 +419,8 @@ fun InfoTip(text: String, modifier: Modifier = Modifier) { */ @Composable fun CopyPill(value: String, label: String = "Copy", modifier: Modifier = Modifier) { - val clipboard = LocalClipboardManager.current + val clipboard = LocalClipboard.current + val scope = rememberCoroutineScope() var copied by remember(value) { mutableStateOf(false) } LaunchedEffect(copied) { if (copied) { delay(1200.milliseconds); copied = false } } val shape = RoundedCornerShape(6.dp) @@ -414,7 +429,7 @@ fun CopyPill(value: String, label: String = "Copy", modifier: Modifier = Modifie .background(if (copied) Tokens.tintGreen else Tokens.tintBlue, shape) .border(1.dp, if (copied) Tokens.okBorder else Tokens.accentBorder, shape) .pointerHoverIcon(PointerIcon.Hand) - .onTap { clipboard.setText(AnnotatedString(value)); copied = true } + .onTap { scope.launch { clipboard.setText(value); copied = true } } .padding(horizontal = 9.dp, vertical = 2.dp), contentAlignment = Alignment.Center, ) { @@ -492,14 +507,15 @@ fun RowAction(label: String, danger: Boolean = false, onClick: () -> Unit) { */ @Composable fun CopyAction(value: String, label: String = "Copy") { - val clipboard = LocalClipboardManager.current + 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 { clipboard.setText(AnnotatedString(value)); copied = true }, + .onTap { scope.launch { clipboard.setText(value); copied = true } }, ) } @@ -524,13 +540,14 @@ fun CopyTarget( modifier: Modifier = Modifier, content: @Composable (copied: Boolean) -> Unit, ) { - val clipboard = LocalClipboardManager.current + val clipboard = LocalClipboard.current + val scope = rememberCoroutineScope() var copied by remember(value) { mutableStateOf(false) } LaunchedEffect(copied) { if (copied) { delay(1200.milliseconds); copied = false } } HoverTip( tip, modifier.pointerHoverIcon(PointerIcon.Hand) - .onTap { clipboard.setText(AnnotatedString(value)); copied = true }, + .onTap { scope.launch { clipboard.setText(value); copied = true } }, ) { content(copied) } } diff --git a/src/main/kotlin/com/gitvantage/ui/Main.kt b/src/main/kotlin/com/gitvantage/ui/Main.kt index 09e0d18..f38507a 100644 --- a/src/main/kotlin/com/gitvantage/ui/Main.kt +++ b/src/main/kotlin/com/gitvantage/ui/Main.kt @@ -34,7 +34,9 @@ import androidx.compose.ui.input.key.isMetaPressed import androidx.compose.ui.input.key.isShiftPressed import androidx.compose.ui.input.key.key import androidx.compose.ui.input.key.type -import androidx.compose.ui.res.painterResource +import androidx.compose.ui.graphics.painter.BitmapPainter +import androidx.compose.ui.graphics.painter.Painter +import androidx.compose.ui.graphics.toComposeImageBitmap import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.DpSize import androidx.compose.ui.unit.dp @@ -56,6 +58,7 @@ import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.delay import kotlinx.coroutines.flow.debounce import kotlin.time.Duration.Companion.milliseconds +import org.jetbrains.skia.Image as SkiaImage /** * `--smoke-test` runs the subsystem checks in [runSmokeChecks] and exits, without opening a window. @@ -97,6 +100,20 @@ private fun adoptContextClassLoader() { } } +/** + * The app icon, for both the window and the title bar. + * + * Decoded straight from the classpath rather than through Compose's `painterResource`, which is + * deprecated in favour of the Compose resources library — a generated `Res` class and a + * `composeResources` folder, for one PNG. Read through our own class, so it doesn't depend on the + * thread's context class loader either (see [adoptContextClassLoader]). + */ +private val appIcon: Painter by lazy { + val bytes = checkNotNull(AppState::class.java.getResourceAsStream("/app-icon.png")) { "app-icon.png missing" } + .use { it.readAllBytes() } + BitmapPainter(SkiaImage.makeFromEncoded(bytes).toComposeImageBitmap()) +} + @OptIn(FlowPreview::class) private fun runApp() = nucleusApplication(backend = NucleusBackend.Tao) { adoptContextClassLoader() @@ -131,7 +148,7 @@ private fun runApp() = nucleusApplication(backend = NucleusBackend.Tao) { state = windowState, visible = windowVisible, title = "GitVantage", - icon = painterResource("app-icon.png"), // taskbar / window icon (X11/Win/macOS; Wayland uses app_id) + icon = appIcon, // taskbar / window icon (X11/Win/macOS; Wayland uses app_id) onPreviewKeyEvent = ::zoomShortcut, ) { // Everything the window draws — the Nucleus-drawn title bar included — sizes itself @@ -153,7 +170,7 @@ private fun runApp() = nucleusApplication(backend = NucleusBackend.Tao) { horizontalArrangement = Arrangement.spacedBy(10.dp), ) { Image( - painter = painterResource("app-icon.png"), + painter = appIcon, contentDescription = "GitVantage", modifier = Modifier.size(22.dp).clip(RoundedCornerShape(6.dp)), ) diff --git a/src/main/kotlin/com/gitvantage/ui/Popups.kt b/src/main/kotlin/com/gitvantage/ui/Popups.kt index af0da3f..99cf27d 100644 --- a/src/main/kotlin/com/gitvantage/ui/Popups.kt +++ b/src/main/kotlin/com/gitvantage/ui/Popups.kt @@ -67,8 +67,7 @@ import com.gitvantage.app.UiFont import com.gitvantage.app.UiScale import com.gitvantage.model.Meta import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.ui.platform.LocalClipboardManager -import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.platform.LocalClipboard import androidx.compose.ui.input.pointer.PointerIcon import androidx.compose.ui.input.pointer.pointerHoverIcon import com.gitvantage.app.DiagLog @@ -513,7 +512,7 @@ private fun DiagnosticsSection(state: AppState) { */ @Composable private fun CopyReportPill(state: AppState) { - val clipboard = LocalClipboardManager.current + val clipboard = LocalClipboard.current val scope = rememberCoroutineScope() var copied by remember { mutableStateOf(false) } LaunchedEffect(copied) { if (copied) { delay(1200.milliseconds); copied = false } } @@ -525,7 +524,7 @@ private fun CopyReportPill(state: AppState) { .pointerHoverIcon(PointerIcon.Hand) .onTap { scope.launch { - clipboard.setText(AnnotatedString(state.diagnosticsReport())) + clipboard.setText(state.diagnosticsReport()) copied = true } }