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
22 changes: 15 additions & 7 deletions frontend/src/components/AppUpdateGate.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
import { AppUpdate, System } from "@/lib/rpc"
import { useProjects } from "@/providers/projects"
import { queuePaste } from "@/lib/terminal/paste-queue"
import { readPref, writePref } from "@/lib/prefs"
import { registerUpdateChecker } from "@/lib/update/update-check"
import { errorText } from "@/lib/utils"

Expand Down Expand Up @@ -42,11 +43,6 @@ export function AppUpdateGate() {
return () => clearInterval(id)
}, [])

useEffect(() => {
registerUpdateChecker(checkNow)
return () => registerUpdateChecker(null)
})

const prompt = (action: UpdateAction & { kind: "update" }) => {
promptedVersion.current = action.version
if (action.canSelfApply) {
Expand All @@ -60,7 +56,7 @@ export function AppUpdateGate() {
let action: UpdateAction
try {
const status = await AppUpdate.Status()
action = decideUpdateAction(status, localStorage.getItem(UPDATE_DISMISSED_KEY))
action = decideUpdateAction(status, readPref(UPDATE_DISMISSED_KEY))
} catch {
return
}
Expand All @@ -79,7 +75,19 @@ export function AppUpdateGate() {
return status
}

const dismiss = (version: string) => localStorage.setItem(UPDATE_DISMISSED_KEY, version)
// The manual check closes over this render's state, so it rides a ref and the
// registration runs once. Declared here rather than beside the poll above:
// it reads checkNow, and an effect with no dependency array — what this was —
// re-registered on every render to the same effect.
const checkNowRef = useRef(checkNow)
checkNowRef.current = checkNow

useEffect(() => {
registerUpdateChecker(() => checkNowRef.current())
return () => registerUpdateChecker(null)
}, [])

const dismiss = (version: string) => writePref(UPDATE_DISMISSED_KEY, version)

// Windows/macOS: swap the binary in place, then offer a one-click restart.
const promptSelfApply = (version: string) => {
Expand Down
9 changes: 5 additions & 4 deletions frontend/src/components/ClaudePluginGate.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import {
} from "@/lib/update/plugin-gate"
import { ClaudePlugin } from "@/lib/rpc"
import { errorText } from "@/lib/utils"
import { readPref, writePref } from "@/lib/prefs"
import { runWithToast } from "@/lib/toast-async"

// ClaudePluginGate checks on startup whether the lich Claude Code plugin is
Expand All @@ -44,8 +45,8 @@ export function ClaudePluginGate() {
const status = await ClaudePlugin.Status()
action = decidePluginAction(
status,
localStorage.getItem(INSTALL_DISMISSED_KEY) === DISMISSED_FLAG,
localStorage.getItem(UPDATE_DISMISSED_KEY),
readPref(INSTALL_DISMISSED_KEY) === DISMISSED_FLAG,
readPref(UPDATE_DISMISSED_KEY),
)
} catch {
return
Expand All @@ -63,7 +64,7 @@ export function ClaudePluginGate() {
action: { label: "Update", onClick: () => void runUpdate() },
cancel: {
label: "Later",
onClick: () => localStorage.setItem(UPDATE_DISMISSED_KEY, version),
onClick: () => writePref(UPDATE_DISMISSED_KEY, version),
},
})
}
Expand All @@ -90,7 +91,7 @@ export function ClaudePluginGate() {
}

const dismissForever = () => {
localStorage.setItem(INSTALL_DISMISSED_KEY, DISMISSED_FLAG)
writePref(INSTALL_DISMISSED_KEY, DISMISSED_FLAG)
setInstallOpen(false)
}

Expand Down
162 changes: 82 additions & 80 deletions frontend/src/components/FooterBar.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { useEffect, useState } from "react"
import { useEffect, useState, type ReactNode } from "react"
import { useNavigate } from "react-router-dom"
import { openPulls } from "@/lib/pulls-card-store"
import { toast } from "sonner"
Expand Down Expand Up @@ -32,6 +32,48 @@ function useNow(): Date {

const two = (n: number): string => String(n).padStart(2, "0")

interface FooterButtonProps {
/** Both the tooltip and the accessible name — one string, one meaning. */
label: string
onClick: () => void
/** Set for a button that toggles a dock panel: it reads as pressed while
* that panel is open. Left off for a plain action. */
pressed?: boolean
disabled?: boolean
/** Roomier padding for a button carrying text beside its glyph. */
wide?: boolean
children: ReactNode
}

// One segment of the status strip: a glyph (sometimes with a readout beside
// it), its meaning in a tooltip, and the accent fill that marks the dock panel
// it opens as the one on screen.
function FooterButton({ label, onClick, pressed, disabled, wide, children }: FooterButtonProps) {
return (
<Tooltip>
<TooltipTrigger
render={
<button
type="button"
onClick={onClick}
disabled={disabled}
aria-pressed={pressed}
aria-label={label}
className={cn(
"flex items-center rounded-md text-muted-foreground transition-colors hover:bg-accent hover:text-accent-foreground disabled:pointer-events-none disabled:opacity-40",
wide ? "gap-1.5 px-1.5 py-1" : "justify-center p-1",
pressed && "bg-accent text-accent-foreground",
)}
/>
}
>
{children}
</TooltipTrigger>
<TooltipContent>{label}</TooltipContent>
</Tooltip>
)
}

interface FooterBarProps {
dock: DockTab | null
onDock: (tab: DockTab) => void
Expand Down Expand Up @@ -104,91 +146,51 @@ export function FooterBar({ dock, onDock }: FooterBarProps) {

return (
<footer className="flex h-9 shrink-0 items-center gap-2 border-t border-border bg-sidebar px-3 text-xs text-muted-foreground">
<Tooltip>
<TooltipTrigger
render={
<button
type="button"
onClick={() => void attachFile()}
disabled={!sessionId}
aria-label="Attach file"
className="flex items-center justify-center rounded-md p-1 text-muted-foreground transition-colors hover:bg-accent hover:text-accent-foreground disabled:pointer-events-none disabled:opacity-40"
/>
}
>
<Plus className="size-4" />
</TooltipTrigger>
<TooltipContent>Attach file</TooltipContent>
</Tooltip>
<FooterButton label="Attach file" onClick={() => void attachFile()} disabled={!sessionId}>
<Plus className="size-4" />
</FooterButton>
{path && (
<Tooltip>
<TooltipTrigger
render={
<button
type="button"
onClick={() => onDock("files")}
aria-pressed={dock === "files"}
aria-label="Browse code"
className={`flex items-center justify-center rounded-md p-1 text-muted-foreground transition-colors hover:bg-accent hover:text-accent-foreground ${
dock === "files" ? "bg-accent text-accent-foreground" : ""
}`}
/>
}
>
<Code className="size-4" />
</TooltipTrigger>
<TooltipContent>Browse code</TooltipContent>
</Tooltip>
<FooterButton
label="Browse code"
onClick={() => onDock("files")}
pressed={dock === "files"}
>
<Code className="size-4" />
</FooterButton>
)}
{status && (
<Tooltip>
<TooltipTrigger
render={
<button
type="button"
onClick={() => onDock("review")}
aria-pressed={dock === "review"}
className={`flex items-center gap-1.5 rounded-md px-1.5 py-1 text-muted-foreground transition-colors hover:bg-accent hover:text-accent-foreground ${
dock === "review" ? "bg-accent text-accent-foreground" : ""
}`}
/>
}
>
{status.files === 0 ? (
<>
<Diff className="size-3.5" /> 0
</>
) : (
<>
<FileText className="size-3.5" />
{status.files}
<span className="opacity-50">·</span>
<DiffStat added={status.added} deleted={status.deleted} />
</>
)}
</TooltipTrigger>
<TooltipContent>Review changes</TooltipContent>
</Tooltip>
<FooterButton
label="Review changes"
onClick={() => onDock("review")}
pressed={dock === "review"}
wide
>
{status.files === 0 ? (
<>
<Diff className="size-3.5" /> 0
</>
) : (
<>
<FileText className="size-3.5" />
{status.files}
<span className="opacity-50">·</span>
<DiffStat added={status.added} deleted={status.deleted} />
</>
)}
</FooterButton>
)}

{pr && projectId && (
<Tooltip>
<TooltipTrigger
render={
<button
type="button"
onClick={() => {
openPulls(basePath)
navigate(`/projects/${projectId}/pulls`)
}}
className="flex items-center gap-1.5 rounded-md px-1.5 py-1 text-muted-foreground transition-colors hover:bg-accent hover:text-accent-foreground"
/>
}
>
<GitPullRequestArrow className="size-3.5" /> PR #{pr.number}
</TooltipTrigger>
<TooltipContent>View pull request</TooltipContent>
</Tooltip>
<FooterButton
label="View pull request"
onClick={() => {
openPulls(basePath)
navigate(`/projects/${projectId}/pulls`)
}}
wide
>
<GitPullRequestArrow className="size-3.5" /> PR #{pr.number}
</FooterButton>
)}

<span className="ml-auto flex items-center gap-4">
Expand Down
3 changes: 2 additions & 1 deletion frontend/src/components/PatchNotesDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
} from "@/components/ui/dialog"
import { System } from "@/lib/rpc"
import type { PatchNotes } from "@/lib/api-types"
import { cn } from "@/lib/utils"

const RELEASE_TAG_BASE = "https://github.com/omartelo/lich/releases/tag/v"

Expand Down Expand Up @@ -85,7 +86,7 @@ export function PatchNotesDialog({ notes, onClose }: PatchNotesDialogProps) {
{groups.map((group) => (
<div key={group.label} className="border-t py-3.5 first:border-t-0 first:pt-1">
<div className="mb-2.5 flex items-center gap-1.5 text-xs font-semibold uppercase tracking-wide text-muted-foreground">
<span className={`size-2 rounded-full ${dotColor(group.label)}`} />
<span className={cn("size-2 rounded-full", dotColor(group.label))} />
{group.label}
</div>
<ul className="flex flex-col gap-2.5">
Expand Down
7 changes: 4 additions & 3 deletions frontend/src/components/PatchNotesGate.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { useEffect, useState } from "react"
import { PatchNotesDialog } from "./PatchNotesDialog"
import { decidePatchNotes, PATCH_NOTES_SEEN_KEY } from "@/lib/update/patch-notes-gate"
import { PatchNotes } from "@/lib/rpc"
import { readPref, writePref } from "@/lib/prefs"
import type { PatchNotes as PatchNotesData } from "@/lib/api-types"

// PatchNotesGate shows lich's "what's new" popup once per release, right after
Expand All @@ -16,9 +17,9 @@ export function PatchNotesGate() {
void PatchNotes.Current()
.then((current) => {
if (!alive) return
const action = decidePatchNotes(current, localStorage.getItem(PATCH_NOTES_SEEN_KEY))
const action = decidePatchNotes(current, readPref(PATCH_NOTES_SEEN_KEY))
if (action.kind === "record") {
localStorage.setItem(PATCH_NOTES_SEEN_KEY, action.version)
writePref(PATCH_NOTES_SEEN_KEY, action.version)
} else if (action.kind === "show") {
setNotes(action.notes)
}
Expand All @@ -32,7 +33,7 @@ export function PatchNotesGate() {
if (!notes) return null

const close = () => {
localStorage.setItem(PATCH_NOTES_SEEN_KEY, notes.version)
writePref(PATCH_NOTES_SEEN_KEY, notes.version)
setNotes(null)
}

Expand Down
4 changes: 2 additions & 2 deletions frontend/src/components/common/IconAction.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import type { ReactNode } from "react"
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"

interface IconAction {
interface IconActionProps {
/** Both the tooltip and the accessible name — one string, one meaning. */
label: string
onClick: () => void
Expand All @@ -11,7 +11,7 @@ interface IconAction {
// A bare icon button for a panel's header strip: no border, no fill at rest,
// its meaning carried by a tooltip. Attach as context, discard changes, collapse
// all, full screen, hide the tree.
export function IconAction({ label, onClick, children }: IconAction) {
export function IconAction({ label, onClick, children }: IconActionProps) {
return (
<Tooltip>
<TooltipTrigger
Expand Down
25 changes: 25 additions & 0 deletions frontend/src/components/common/SkeletonLines.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import { Skeleton } from "@/components/ui/skeleton"
import { cn } from "@/lib/utils"

interface SkeletonLinesProps {
/** Tailwind width classes, one per line. Ragged on purpose — see below. */
widths: readonly string[]
/** Line height class; the default is a line of prose. */
height?: string
className?: string
}

// A stack of placeholder bars at ragged widths. The raggedness is the whole
// point: equal bars read as a loading widget, uneven ones read as the text
// that is coming — which is what keeps a screen-sized wait from looking like a
// failure. Each caller brings its own widths, because what is arriving differs
// (prose, file names, code).
export function SkeletonLines({ widths, height = "h-3", className }: SkeletonLinesProps) {
return (
<>
{widths.map((width) => (
<Skeleton key={width} className={cn(height, width, className)} />
))}
</>
)
}
12 changes: 8 additions & 4 deletions frontend/src/components/diff/FileDiff.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -65,9 +65,10 @@ export function FileDiff({ file, onInject, onDiscard, bulk, viewed, onViewed }:
return (
<section>
<div
className={`sticky top-0 z-10 flex w-full items-center gap-2 bg-sidebar px-2 py-1 text-xs ${
expanded ? "border-b border-border/60" : ""
}`}
className={cn(
"sticky top-0 z-10 flex w-full items-center gap-2 bg-sidebar px-2 py-1 text-xs",
expanded && "border-b border-border/60",
)}
>
<button
type="button"
Expand All @@ -80,7 +81,10 @@ export function FileDiff({ file, onInject, onDiscard, bulk, viewed, onViewed }:
>
<Chevron className="size-3.5 shrink-0 text-muted-foreground" />
<span
className={`flex size-5 shrink-0 items-center justify-center rounded text-[0.5625rem] font-bold ${badge.className}`}
className={cn(
"flex size-5 shrink-0 items-center justify-center rounded text-[0.5625rem] font-bold",
badge.className,
)}
>
{badge.abbr}
</span>
Expand Down
Loading