1import type { ReactNode } from "react";
2
3import { Spinner } from "~/components/ui/spinner";
4import { Button } from "~/components/ui/button";
5import { Toggle } from "~/components/ui/toggle";
6import { Tooltip, TooltipPopup, TooltipTrigger } from "~/components/ui/tooltip";
7import { DIFF_SURFACE_THEME_UNSAFE_CSS } from "~/lib/diffRendering";
8import { cn } from "~/lib/utils";
9
10/**
11 * One header row for every file surface in the side panel, whether the file
12 * comes from the workspace or was captured as an attachment: crumbs on the
13 * left, icon-only actions on the right. Attachments and workspace files must
14 * not grow separate chrome.
15 */
16export const FILE_SURFACE_SUBHEADER_CLASS =
17 "flex h-10 min-h-10 shrink-0 items-center gap-2 border-b border-border/60 bg-background px-3 in-data-[preview-panel-mode=inline]:mb-3 in-data-[preview-panel-mode=inline]:h-7 in-data-[preview-panel-mode=inline]:min-h-7 in-data-[preview-panel-mode=inline]:border-b-transparent";
18
19export const FILE_LINK_REVEAL_ATTRIBUTE = "data-file-link-reveal";
20
21export const FILE_LINK_REVEAL_UNSAFE_CSS = `
22 ${DIFF_SURFACE_THEME_UNSAFE_CSS}
23
24 diffs-container {
25 --diffs-bg: var(--code-background, var(--background)) !important;
26 --diffs-light-bg: var(--code-background, var(--background)) !important;
27 --diffs-dark-bg: var(--code-background, var(--background)) !important;
28 background-color: var(--code-background, var(--background)) !important;
29 color: var(--code-foreground, var(--foreground)) !important;
30 }
31
32 /* Tint through --diffs-line-bg, not background-color. The editor paints row
33 tints on a layer below its text selection; a background on the row itself
34 covers the selection and makes selected text on this line invisible. */
35 [${FILE_LINK_REVEAL_ATTRIBUTE}][data-line] {
36 --diffs-line-bg: light-dark(
37 color-mix(
38 in lab,
39 var(--diffs-computed-diff-line-bg) 82%,
40 var(--diffs-bg-selection-override, var(--diffs-selection-base))
41 ),
42 color-mix(
43 in lab,
44 var(--diffs-computed-diff-line-bg) 75%,
45 var(--diffs-bg-selection-override, var(--diffs-selection-base))
46 )
47 ) !important;
48 }
49
50 [${FILE_LINK_REVEAL_ATTRIBUTE}][data-column-number] {
51 background-color: light-dark(
52 color-mix(
53 in lab,
54 var(--diffs-computed-diff-line-bg) 75%,
55 var(--diffs-bg-selection-number-override, var(--diffs-selection-base))
56 ),
57 color-mix(
58 in lab,
59 var(--diffs-computed-diff-line-bg) 60%,
60 var(--diffs-bg-selection-number-override, var(--diffs-selection-base))
61 )
62 ) !important;
63 color: var(--diffs-selection-number-fg) !important;
64 }
65`;
66
67/**
68 * An icon-only header action with its label in a tooltip, the same control workspace files use.
69 * A `pressed` action is a toggle and says so; a command (Copy, Save, Close) is a plain button,
70 * because announcing it as an unpressed toggle tells a screen reader it has a state it has not.
71 */
72export function FileSurfaceAction(props: {
73 readonly label: string;
74 readonly pressed?: boolean;
75 readonly disabled?: boolean;
76 readonly onPress: () => void;
77 readonly children: ReactNode;
78}) {
79 const pressed = props.pressed;
80 return (
81 <Tooltip>
82 <TooltipTrigger
83 render={
84 pressed === undefined ? (
85 <Button
86 type="button"
87 className="shrink-0"
88 disabled={props.disabled ?? false}
89 onClick={props.onPress}
90 aria-label={props.label}
91 variant="ghost"
92 size="icon-sm"
93 >
94 {props.children}
95 </Button>
96 ) : (
97 <Toggle
98 className="shrink-0"
99 pressed={pressed}
100 disabled={props.disabled ?? false}
101 onPressedChange={props.onPress}
102 aria-label={props.label}
103 variant="ghost"
104 size="sm"
105 >
106 {props.children}
107 </Toggle>
108 )
109 }
110 />
111 <TooltipPopup>{props.label}</TooltipPopup>
112 </Tooltip>
113 );
114}
115
116export function FileSurfaceNotice(props: { readonly children: ReactNode }) {
117 return (
118 <div
119 role="status"
120 className="shrink-0 border-b border-warning/20 bg-warning-surface px-3 py-1.5 text-2xs text-warning-foreground"
121 >
122 {props.children}
123 </div>
124 );
125}
126
127export function FileSurfaceLoading(props: { readonly className?: string }) {
128 return (
129 <div
130 role="status"
131 aria-label="Loading file"
132 className={cn(
133 "flex min-h-0 flex-1 items-center justify-center text-muted-foreground",
134 props.className,
135 )}
136 >
137 <Spinner size="lg" />
138 </div>
139 );
140}
141
142export function FileSurfaceFailure(props: {
143 readonly message: string;
144 readonly onRetry?: () => void;
145}) {
146 return (
147 <div
148 role="alert"
149 className="flex min-h-0 flex-1 flex-col items-center justify-center gap-3 px-6 text-center text-xs leading-relaxed"
150 >
151 <p className="text-destructive">{props.message}</p>
152 {props.onRetry ? (
153 <button
154 type="button"
155 onClick={props.onRetry}
156 className="rounded-md border border-input px-2.5 py-1 text-xs text-foreground hover:bg-accent focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring"
157 >
158 Try again
159 </button>
160 ) : null}
161 </div>
162 );
163}