Skip to content

Commit 2a4ba5d

Browse files
committed
add Save and Fork buttons to Project panel
1 parent 1f1e740 commit 2a4ba5d

File tree

5 files changed

+113
-28
lines changed

5 files changed

+113
-28
lines changed

packages/jsrepl/src/app/repl/[[...slug]]/components/info-panel-section.tsx

+1-1
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,7 @@ export function InfoPanelSection({ id: sectionId }: { id: string }) {
5151
</PanelSectionHeader>
5252

5353
<PanelSectionContent className="text-muted-foreground flex select-text flex-col p-4 pr-2 pt-2 text-xs">
54-
<h3 className="text-accent-foreground text-[0.8125rem] font-semibold leading-tight">
54+
<h3 className="text-accent-foreground text-[0.8125rem] font-semibold leading-4">
5555
{replState.title || 'Untitled REPL'}
5656
</h3>
5757
{replState.description && <p className="mt-2">{replState.description}</p>}

packages/jsrepl/src/app/repl/[[...slug]]/components/project-panel.tsx

+37-1
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,53 @@
1+
import { LucideGitFork, LucideLoader, LucideSave } from 'lucide-react'
12
import { PanelSections } from '@/components/panel-section'
3+
import { Button } from '@/components/ui/button'
4+
import { useReplSave } from '@/hooks/useReplSave'
25
import { useUserStoredState } from '@/hooks/useUserStoredState'
36
import FilesPanelSection from './files-panel-section'
47
import { InfoPanelSection } from './info-panel-section'
58

69
export function ProjectPanel() {
710
const [userState, setUserState] = useUserStoredState()
11+
const [, saveState, { isSaving, isForking, allowSave, forkState, allowFork }] = useReplSave()
812

913
return (
1014
<>
11-
<div className="h-repl-header text-muted-foreground bg-secondary flex items-center gap-2 pl-4 pr-1 text-xs font-semibold leading-6">
15+
<div className="h-repl-header text-muted-foreground bg-secondary flex items-center gap-2 pl-4 pr-3 text-xs font-semibold leading-6">
1216
<span className="flex-1">PROJECT</span>
1317
</div>
1418

19+
<div className="flex items-center gap-2 pb-3 pl-4 pt-1">
20+
<Button
21+
size="xs"
22+
variant="secondary"
23+
className="text-accent-foreground gap-1"
24+
disabled={!allowSave}
25+
onClick={() => saveState()}
26+
>
27+
{isSaving ? (
28+
<LucideLoader className="animate-spin" size={15} />
29+
) : (
30+
<LucideSave size={15} className="opacity-80" strokeWidth={1.5} />
31+
)}
32+
Save
33+
</Button>
34+
35+
<Button
36+
size="xs"
37+
variant="secondary"
38+
className="text-accent-foreground gap-1"
39+
disabled={!allowFork}
40+
onClick={() => forkState()}
41+
>
42+
{isForking ? (
43+
<LucideLoader className="animate-spin" size={15} />
44+
) : (
45+
<LucideGitFork size={15} className="opacity-80" strokeWidth={1.5} />
46+
)}
47+
Fork
48+
</Button>
49+
</div>
50+
1551
<div className="flex-1 overflow-y-auto">
1652
<PanelSections
1753
value={userState.projectPanelExpandedSections}

packages/jsrepl/src/components/panel-section.tsx

+5-6
Original file line numberDiff line numberDiff line change
@@ -24,11 +24,7 @@ const PanelSection = React.forwardRef<
2424
React.ElementRef<typeof AccordionPrimitive.Item>,
2525
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Item>
2626
>(({ className, ...props }, ref) => (
27-
<AccordionPrimitive.Item
28-
ref={ref}
29-
className={cn('group/panel-section border-b last:border-b-0', className)}
30-
{...props}
31-
/>
27+
<AccordionPrimitive.Item ref={ref} className={cn('group/panel-section', className)} {...props} />
3228
))
3329
PanelSection.displayName = 'PanelSection'
3430

@@ -38,7 +34,10 @@ const PanelSectionHeader = React.forwardRef<
3834
>(({ className, children }, ref) => (
3935
<AccordionPrimitive.Header
4036
ref={ref}
41-
className={cn('bg-secondary sticky -top-px z-[1] flex', className)}
37+
className={cn(
38+
'bg-secondary sticky top-0 z-[1] flex border-t group-first/panel-section:border-t-0',
39+
className
40+
)}
4241
>
4342
{children}
4443
</AccordionPrimitive.Header>

packages/jsrepl/src/components/providers/repl-save-provider.tsx

+50-17
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ export type ReplSaveContextType = {
3131
isDirty: boolean
3232
isEffectivelyDirty: boolean
3333
isSaving: boolean
34+
isForking: boolean
3435
allowSave: boolean
3536
allowFork: boolean
3637
}
@@ -48,6 +49,7 @@ export default function ReplSaveProvider({ children }: { children: React.ReactNo
4849
const { flushPendingChanges } = useWritableModels()
4950
const [savedState, setSavedState] = useReplSavedState()
5051
const [isSaving, setIsSaving] = useState(false)
52+
const [isForking, setIsForking] = useState(false)
5153

5254
const formatOnSave = useMemo(
5355
() => userStoredState.workbench.formatOnSave,
@@ -67,18 +69,18 @@ export default function ReplSaveProvider({ children }: { children: React.ReactNo
6769
const isDirty = useMemo<boolean>(() => checkDirty(state, savedState), [state, savedState])
6870

6971
const allowSave = useMemo<boolean>(
70-
() => !isSaving && (isNew || isDirty),
71-
[isSaving, isNew, isDirty]
72+
() => !isSaving && !isForking && (isNew || isEffectivelyDirty),
73+
[isSaving, isForking, isNew, isEffectivelyDirty]
7274
)
7375

74-
const allowFork = useMemo<boolean>(() => !isSaving && !isNew, [isSaving, isNew])
76+
const allowFork = useMemo<boolean>(() => !isForking && !isNew, [isForking, isNew])
7577

7678
useEffect(() => {
7779
stateRef.current = state
7880
}, [state])
7981

80-
const handleSaveError = useCallback(
81-
async (error: unknown) => {
82+
const handleSaveOrForkError = useCallback(
83+
async (type: 'save' | 'fork', error: unknown) => {
8284
const isAborted = isAbortError(error instanceof ResponseError ? error.cause : error)
8385
if (isAborted) {
8486
return
@@ -88,21 +90,26 @@ export default function ReplSaveProvider({ children }: { children: React.ReactNo
8890

8991
if (error instanceof ResponseError) {
9092
if (error.status === 401) {
91-
toast.info('Please sign in to save your changes.', {
92-
action: {
93-
label: 'Sign in with Github',
94-
onClick: () => {
95-
signInWithGithub({ popup: true })
93+
toast.info(
94+
type === 'save'
95+
? 'Please sign in to save your changes.'
96+
: 'Please sign in to fork this REPL.',
97+
{
98+
action: {
99+
label: 'Sign in with Github',
100+
onClick: () => {
101+
signInWithGithub({ popup: true })
102+
},
96103
},
97-
},
98-
})
104+
}
105+
)
99106
} else {
100107
toast.error(error.message, {
101108
description: `${error.status} ${error.statusText}: ${error.cause ?? 'Something went wrong :('}`,
102109
})
103110
}
104111
} else {
105-
toast.error('Failed to save', {
112+
toast.error(`Failed to ${type}`, {
106113
description: error instanceof Error ? error.message : 'Unknown error',
107114
})
108115
}
@@ -123,7 +130,7 @@ export default function ReplSaveProvider({ children }: { children: React.ReactNo
123130

124131
return await callback({ signal })
125132
} catch (error) {
126-
handleSaveError(error)
133+
handleSaveOrForkError('save', error)
127134
return false
128135
} finally {
129136
if (signal === abortControllerRef.current?.signal) {
@@ -132,7 +139,32 @@ export default function ReplSaveProvider({ children }: { children: React.ReactNo
132139
}
133140
}
134141
},
135-
[handleSaveError]
142+
[handleSaveOrForkError]
143+
)
144+
145+
const forkWrapper = useCallback(
146+
async (callback: (args: { signal: AbortSignal }) => Promise<boolean>) => {
147+
let signal: AbortSignal | null = null
148+
149+
try {
150+
setIsForking(true)
151+
152+
abortControllerRef.current?.abort()
153+
abortControllerRef.current = new AbortController()
154+
signal = abortControllerRef.current.signal
155+
156+
return await callback({ signal })
157+
} catch (error) {
158+
handleSaveOrForkError('fork', error)
159+
return false
160+
} finally {
161+
if (signal === abortControllerRef.current?.signal) {
162+
setIsForking(false)
163+
abortControllerRef.current = null
164+
}
165+
}
166+
},
167+
[handleSaveOrForkError]
136168
)
137169

138170
const saveState = useCallback<() => Promise<boolean>>(async () => {
@@ -229,7 +261,7 @@ export default function ReplSaveProvider({ children }: { children: React.ReactNo
229261
])
230262

231263
const forkState = useCallback<() => Promise<boolean>>(async () => {
232-
return await saveWrapper(forkFn)
264+
return await forkWrapper(forkFn)
233265

234266
async function forkFn({ signal }: { signal: AbortSignal }) {
235267
if (!user) {
@@ -280,7 +312,7 @@ export default function ReplSaveProvider({ children }: { children: React.ReactNo
280312
user,
281313
setState,
282314
setSavedState,
283-
saveWrapper,
315+
forkWrapper,
284316
signInWithGithub,
285317
queryClient,
286318
supabase,
@@ -296,6 +328,7 @@ export default function ReplSaveProvider({ children }: { children: React.ReactNo
296328
isEffectivelyDirty,
297329
isDirty,
298330
isSaving,
331+
isForking,
299332
allowSave,
300333
allowFork,
301334
}}

packages/jsrepl/src/hooks/useReplSave.tsx

+20-3
Original file line numberDiff line numberDiff line change
@@ -8,13 +8,30 @@ export function useReplSave(): [
88
{
99
isNew: boolean
1010
isDirty: boolean
11+
isEffectivelyDirty: boolean
1112
isSaving: boolean
13+
isForking: boolean
1214
allowSave: boolean
1315
forkState: () => Promise<boolean>
1416
allowFork: boolean
1517
},
1618
] {
17-
const { savedState, saveState, forkState, isNew, isDirty, isSaving, allowSave, allowFork } =
18-
useContext(ReplSaveContext)!
19-
return [savedState, saveState, { isNew, isDirty, isSaving, allowSave, forkState, allowFork }]
19+
const {
20+
savedState,
21+
saveState,
22+
forkState,
23+
isNew,
24+
isDirty,
25+
isEffectivelyDirty,
26+
isSaving,
27+
isForking,
28+
allowSave,
29+
allowFork,
30+
} = useContext(ReplSaveContext)!
31+
32+
return [
33+
savedState,
34+
saveState,
35+
{ isNew, isDirty, isEffectivelyDirty, isSaving, isForking, allowSave, forkState, allowFork },
36+
]
2037
}

0 commit comments

Comments
 (0)