@tscircuit/fake-snippets 0.0.98 → 0.0.99

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,4 +1,3 @@
1
- import "dotenv/config"
2
1
  import { withRouteSpec } from "fake-snippets-api/lib/middleware/with-winter-spec"
3
2
  import { z } from "zod"
4
3
  import OpenAI from "openai"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tscircuit/fake-snippets",
3
- "version": "0.0.98",
3
+ "version": "0.0.99",
4
4
  "type": "module",
5
5
  "repository": {
6
6
  "type": "git",
@@ -75,7 +75,7 @@
75
75
  "@radix-ui/react-toggle-group": "^1.1.0",
76
76
  "@radix-ui/react-tooltip": "^1.1.2",
77
77
  "@tailwindcss/typography": "^0.5.16",
78
- "@tscircuit/3d-viewer": "^0.0.279",
78
+ "@tscircuit/3d-viewer": "^0.0.303",
79
79
  "@tscircuit/assembly-viewer": "^0.0.1",
80
80
  "@tscircuit/create-snippet-url": "^0.0.8",
81
81
  "@tscircuit/eval": "^0.0.244",
@@ -83,7 +83,7 @@
83
83
  "@tscircuit/mm": "^0.0.8",
84
84
  "@tscircuit/pcb-viewer": "^1.11.194",
85
85
  "@tscircuit/prompt-benchmarks": "^0.0.28",
86
- "@tscircuit/runframe": "^0.0.669",
86
+ "@tscircuit/runframe": "0.0.715",
87
87
  "@tscircuit/schematic-viewer": "^2.0.21",
88
88
  "@types/babel__standalone": "^7.1.7",
89
89
  "@types/bun": "^1.1.10",
@@ -187,6 +187,7 @@
187
187
  "wouter": "^3.3.5",
188
188
  "zod": "^3.23.8",
189
189
  "zustand": "^4.5.5",
190
- "zustand-hoist": "^2.0.1"
190
+ "zustand-hoist": "^2.0.1",
191
+ "circuit-json-to-spice": "^0.0.6"
191
192
  }
192
193
  }
@@ -12,6 +12,7 @@ import { downloadDsnFile } from "@/lib/download-fns/download-dsn-file-fn"
12
12
  import { downloadFabricationFiles } from "@/lib/download-fns/download-fabrication-files"
13
13
  import { downloadSchematicSvg } from "@/lib/download-fns/download-schematic-svg"
14
14
  import { downloadReadableNetlist } from "@/lib/download-fns/download-readable-netlist"
15
+ import { downloadSpiceFile } from "@/lib/download-fns/download-spice-file"
15
16
  import { downloadAssemblySvg } from "@/lib/download-fns/download-assembly-svg"
16
17
  import { usePcbDownloadDialog } from "@/components/dialogs/pcb-download-dialog"
17
18
  import { downloadKicadFiles } from "@/lib/download-fns/download-kicad-files"
@@ -214,6 +215,18 @@ export function DownloadButtonAndMenu({
214
215
  txt
215
216
  </span>
216
217
  </DropdownMenuItem>
218
+ <DropdownMenuItem
219
+ className="text-xs"
220
+ onSelect={() => {
221
+ downloadSpiceFile(circuitJson, unscopedName || "circuit")
222
+ }}
223
+ >
224
+ <Download className="mr-1 h-3 w-3" />
225
+ <span className="flex-grow mr-6">SPICE Netlist</span>
226
+ <span className="text-[0.6rem] opacity-80 bg-blue-500 text-white font-mono rounded-md px-1 text-center py-0.5 mr-1">
227
+ spice
228
+ </span>
229
+ </DropdownMenuItem>
217
230
  <DropdownMenuItem
218
231
  className="text-xs"
219
232
  onSelect={() => {
@@ -1,6 +1,14 @@
1
1
  import React, { useState } from "react"
2
2
  import { cn } from "@/lib/utils"
3
- import { File, Folder, MoreVertical, PanelRightOpen, Plus } from "lucide-react"
3
+ import {
4
+ File,
5
+ Folder,
6
+ MoreVertical,
7
+ PanelRightOpen,
8
+ Plus,
9
+ Trash2,
10
+ Pencil,
11
+ } from "lucide-react"
4
12
  import { TreeView, TreeDataItem } from "@/components/ui/tree-view"
5
13
  import { isHiddenFile } from "./ViewPackagePage/utils/is-hidden-file"
6
14
  import { Input } from "@/components/ui/input"
@@ -16,8 +24,12 @@ import type {
16
24
  ICreateFileResult,
17
25
  IDeleteFileProps,
18
26
  IDeleteFileResult,
27
+ IRenameFileProps,
28
+ IRenameFileResult,
19
29
  } from "@/hooks/useFileManagement"
20
30
  import { useToast } from "@/hooks/use-toast"
31
+ import { useGlobalStore } from "@/hooks/use-global-store"
32
+ import type { Package } from "fake-snippets-api/lib/db/schema"
21
33
  type FileName = string
22
34
 
23
35
  interface FileSidebarProps {
@@ -28,6 +40,10 @@ interface FileSidebarProps {
28
40
  fileSidebarState: ReturnType<typeof useState<boolean>>
29
41
  handleCreateFile: (props: ICreateFileProps) => ICreateFileResult
30
42
  handleDeleteFile: (props: IDeleteFileProps) => IDeleteFileResult
43
+ handleRenameFile: (props: IRenameFileProps) => IRenameFileResult
44
+ isCreatingFile: boolean
45
+ setIsCreatingFile: React.Dispatch<React.SetStateAction<boolean>>
46
+ pkg?: Package
31
47
  }
32
48
 
33
49
  const FileSidebar: React.FC<FileSidebarProps> = ({
@@ -38,12 +54,18 @@ const FileSidebar: React.FC<FileSidebarProps> = ({
38
54
  fileSidebarState,
39
55
  handleCreateFile,
40
56
  handleDeleteFile,
57
+ handleRenameFile,
58
+ isCreatingFile,
59
+ setIsCreatingFile,
60
+ pkg,
41
61
  }) => {
42
62
  const [sidebarOpen, setSidebarOpen] = fileSidebarState
43
63
  const [newFileName, setNewFileName] = useState("")
44
- const [isCreatingFile, setIsCreatingFile] = useState(false)
45
64
  const [errorMessage, setErrorMessage] = useState("")
65
+ const [renamingFile, setRenamingFile] = useState<string | null>(null)
46
66
  const { toast } = useToast()
67
+ const session = useGlobalStore((s) => s.session)
68
+ const canModifyFiles = true
47
69
 
48
70
  const transformFilesToTreeData = (
49
71
  files: Record<FileName, string>,
@@ -79,20 +101,59 @@ const FileSidebar: React.FC<FileSidebarProps> = ({
79
101
  ) {
80
102
  currentNode[segment] = {
81
103
  id: itemId,
82
- name: isLeafNode ? segment : segment,
104
+ name: segment,
105
+ isRenaming: renamingFile === itemId,
106
+ onRename: (newFilename: string) => {
107
+ // Preserve the folder structure when renaming
108
+ const oldPath = itemId
109
+ const pathParts = oldPath.split("/").filter((part) => part !== "") // Remove empty segments
110
+ let newPath: string
111
+
112
+ if (pathParts.length > 1) {
113
+ // File is in a folder, preserve the folder structure
114
+ const folderPath = pathParts.slice(0, -1).join("/")
115
+ newPath = folderPath + "/" + newFilename
116
+ } else {
117
+ // File is in root, just use the new filename
118
+ newPath = newFilename
119
+ }
120
+
121
+ // Preserve leading slash if original path had one
122
+ if (oldPath.startsWith("/") && !newPath.startsWith("/")) {
123
+ newPath = "/" + newPath
124
+ }
125
+
126
+ const { fileRenamed } = handleRenameFile({
127
+ oldFilename: itemId,
128
+ newFilename: newPath,
129
+ onError: (error) => {
130
+ toast({
131
+ title: `Error renaming file`,
132
+ description: error.message,
133
+ variant: "destructive",
134
+ })
135
+ },
136
+ })
137
+ if (fileRenamed) {
138
+ setRenamingFile(null)
139
+ }
140
+ },
141
+ onCancelRename: () => {
142
+ setRenamingFile(null)
143
+ },
83
144
  icon: isLeafNode ? File : Folder,
84
145
  onClick: isLeafNode ? () => onFileSelect(absolutePath) : undefined,
85
146
  draggable: false,
86
147
  droppable: !isLeafNode,
87
148
  children: isLeafNode ? undefined : {},
88
- actions: (
149
+ actions: canModifyFiles ? (
89
150
  <>
90
151
  <DropdownMenu key={itemId}>
91
152
  <DropdownMenuTrigger asChild>
92
153
  <MoreVertical className="w-4 h-4 text-gray-500 hover:text-gray-700" />
93
154
  </DropdownMenuTrigger>
94
155
  <DropdownMenuContent
95
- className="w-48 bg-white shadow-lg rounded-md border-4 z-[100] border-white"
156
+ className="w-fit bg-white shadow-lg rounded-md border-4 z-[100] border-white"
96
157
  style={{
97
158
  position: "absolute",
98
159
  top: "100%",
@@ -103,13 +164,24 @@ const FileSidebar: React.FC<FileSidebarProps> = ({
103
164
  }}
104
165
  >
105
166
  <DropdownMenuGroup>
167
+ {isLeafNode && (
168
+ <DropdownMenuItem
169
+ onClick={() => {
170
+ setRenamingFile(itemId)
171
+ }}
172
+ className="flex items-center px-3 py-1 text-xs text-black hover:bg-gray-100 cursor-pointer"
173
+ >
174
+ <Pencil className="mr-2 h-3 w-3" />
175
+ Rename
176
+ </DropdownMenuItem>
177
+ )}
106
178
  <DropdownMenuItem
107
179
  onClick={() => {
108
180
  const { fileDeleted } = handleDeleteFile({
109
- filename: relativePath,
181
+ filename: itemId,
110
182
  onError: (error) => {
111
183
  toast({
112
- title: `Error deleting file ${relativePath}`,
184
+ title: `Error deleting file ${itemId}`,
113
185
  description: error.message,
114
186
  })
115
187
  },
@@ -118,15 +190,16 @@ const FileSidebar: React.FC<FileSidebarProps> = ({
118
190
  setErrorMessage("")
119
191
  }
120
192
  }}
121
- className="flex items-center px-4 py-1 text-sm text-gray-700 hover:bg-gray-100 cursor-pointer"
193
+ className="flex items-center px-3 py-1 text-xs text-red-600 hover:bg-gray-100 cursor-pointer"
122
194
  >
195
+ <Trash2 className="mr-2 h-3 w-3" />
123
196
  Delete
124
197
  </DropdownMenuItem>
125
198
  </DropdownMenuGroup>
126
199
  </DropdownMenuContent>
127
200
  </DropdownMenu>
128
201
  </>
129
- ),
202
+ ) : undefined,
130
203
  }
131
204
  }
132
205
 
@@ -152,7 +225,7 @@ const FileSidebar: React.FC<FileSidebarProps> = ({
152
225
  }
153
226
 
154
227
  const treeData = transformFilesToTreeData(files)
155
- // console.log("treeData", files)
228
+
156
229
  const handleCreateFileInline = () => {
157
230
  const { newFileCreated } = handleCreateFile({
158
231
  newFileName,
@@ -19,34 +19,46 @@ export const LogContent = ({
19
19
  type?: "info" | "success" | "error"
20
20
  msg?: string
21
21
  message?: string
22
- timestamp?: string
22
+ timestamp?: string | number
23
+ [key: string]: unknown
23
24
  }>
24
25
  error?: ErrorObject | string | null
25
26
  }) => {
26
27
  return (
27
28
  <div className="font-mono text-xs space-y-1 min-w-0">
28
29
  {logs.map((log, i) => {
29
- const text = log.msg ?? log.message
30
+ const { type, msg, message, timestamp, ...rest } = log
31
+ const text = msg ?? message
30
32
  if (!text) return null
31
33
 
32
34
  return (
33
35
  <div
34
36
  key={i}
35
37
  className={`break-words whitespace-pre-wrap ${
36
- log.type === "error"
38
+ type === "error"
37
39
  ? "text-red-600"
38
- : log.type === "success"
40
+ : type === "success"
39
41
  ? "text-green-600"
40
42
  : "text-gray-600"
41
43
  }`}
42
44
  >
43
- {log.timestamp && (
45
+ {timestamp !== undefined && (
44
46
  <span className="text-gray-500 whitespace-nowrap">
45
- {new Date(log.timestamp).toLocaleTimeString()}
47
+ {new Date(Number(timestamp)).toLocaleTimeString()}
46
48
  </span>
47
49
  )}
48
- {log.timestamp && " "}
50
+ {timestamp !== undefined && " "}
49
51
  <span className="break-all">{text}</span>
52
+ {Object.keys(rest).filter((k) => k !== "package_release_id")
53
+ .length > 0 && (
54
+ <span className="text-gray-500">
55
+ {" "}
56
+ {Object.entries(rest)
57
+ .filter(([key]) => key !== "package_release_id")
58
+ .map(([key, value]) => `${key}: ${String(value)}`)
59
+ .join(" ")}
60
+ </span>
61
+ )}
50
62
  </div>
51
63
  )
52
64
  })}
@@ -16,6 +16,7 @@ import { toastManualEditConflicts } from "@/lib/utils/toastManualEditConflicts"
16
16
  import { ManualEditEvent } from "@tscircuit/props"
17
17
  import { useFileManagement } from "@/hooks/useFileManagement"
18
18
  import { DEFAULT_CODE } from "@/lib/utils/package-utils"
19
+ import { useGlobalStore } from "@/hooks/use-global-store"
19
20
 
20
21
  interface Props {
21
22
  pkg?: Package
@@ -38,6 +39,7 @@ export interface CodeAndPreviewState {
38
39
 
39
40
  export function CodeAndPreview({ pkg, projectUrl }: Props) {
40
41
  const { toast } = useToast()
42
+ const session = useGlobalStore((s) => s.session)
41
43
  const urlParams = useUrlParams()
42
44
 
43
45
  const templateFromUrl = useMemo(
@@ -75,6 +77,7 @@ export function CodeAndPreview({ pkg, projectUrl }: Props) {
75
77
  setLocalFiles,
76
78
  localFiles,
77
79
  initialFiles,
80
+ renameFile,
78
81
  } = useFileManagement({
79
82
  templateCode: templateFromUrl?.code,
80
83
  currentPackage: pkg,
@@ -208,6 +211,8 @@ export function CodeAndPreview({ pkg, projectUrl }: Props) {
208
211
  isSaving={isSaving}
209
212
  handleCreateFile={createFile}
210
213
  handleDeleteFile={deleteFile}
214
+ handleRenameFile={renameFile}
215
+ pkg={pkg}
211
216
  currentFile={currentFile}
212
217
  onFileSelect={onFileSelect}
213
218
  files={localFiles}
@@ -35,6 +35,7 @@ import CodeEditorHeader, {
35
35
  import FileSidebar from "../FileSidebar"
36
36
  import { findTargetFile } from "@/lib/utils/findTargetFile"
37
37
  import type { PackageFile } from "@/types/package"
38
+ import type { Package } from "fake-snippets-api/lib/db/schema"
38
39
  import { useShikiHighlighter } from "@/hooks/use-shiki-highlighter"
39
40
  import QuickOpen from "./QuickOpen"
40
41
  import GlobalFindReplace from "./GlobalFindReplace"
@@ -43,6 +44,8 @@ import {
43
44
  ICreateFileResult,
44
45
  IDeleteFileProps,
45
46
  IDeleteFileResult,
47
+ IRenameFileProps,
48
+ IRenameFileResult,
46
49
  } from "@/hooks/useFileManagement"
47
50
  import { isHiddenFile } from "../ViewPackagePage/utils/is-hidden-file"
48
51
  import { inlineCopilot } from "codemirror-copilot"
@@ -65,14 +68,18 @@ export const CodeEditor = ({
65
68
  pkgFilesLoaded,
66
69
  currentFile,
67
70
  onFileSelect,
71
+ handleRenameFile,
68
72
  handleCreateFile,
69
73
  handleDeleteFile,
74
+ pkg,
70
75
  }: {
71
76
  onCodeChange: (code: string, filename?: string) => void
72
77
  files: PackageFile[]
73
78
  isSaving?: boolean
74
79
  handleCreateFile: (props: ICreateFileProps) => ICreateFileResult
75
80
  handleDeleteFile: (props: IDeleteFileProps) => IDeleteFileResult
81
+ handleRenameFile: (props: IRenameFileProps) => IRenameFileResult
82
+ pkg?: Package
76
83
  readOnly?: boolean
77
84
  isStreaming?: boolean
78
85
  pkgFilesLoaded?: boolean
@@ -108,6 +115,9 @@ export const CodeEditor = ({
108
115
  return files.find((x) => x.path === "index.tsx")?.path || "index.tsx"
109
116
  }, [files])
110
117
 
118
+ const [sidebarOpen, setSidebarOpen] = useState(false)
119
+ const [isCreatingFile, setIsCreatingFile] = useState(false)
120
+
111
121
  // Set current file on component mount
112
122
  useEffect(() => {
113
123
  if (files.length === 0 || !pkgFilesLoaded || currentFile) return
@@ -786,10 +796,14 @@ export const CodeEditor = ({
786
796
  }
787
797
  })
788
798
 
799
+ useHotkeyCombo("cmd+m", () => {
800
+ setSidebarOpen(true)
801
+ setIsCreatingFile(true)
802
+ })
803
+
789
804
  if (isStreaming) {
790
805
  return <div className="font-mono whitespace-pre-wrap text-xs">{code}</div>
791
806
  }
792
- const [sidebarOpen, setSidebarOpen] = useState(false)
793
807
  return (
794
808
  <div className="flex h-[98vh] w-full overflow-hidden">
795
809
  <FileSidebar
@@ -800,7 +814,11 @@ export const CodeEditor = ({
800
814
  }
801
815
  onFileSelect={(path) => handleFileChange(path)}
802
816
  handleCreateFile={handleCreateFile}
817
+ handleRenameFile={handleRenameFile}
803
818
  handleDeleteFile={handleDeleteFile}
819
+ isCreatingFile={isCreatingFile}
820
+ setIsCreatingFile={setIsCreatingFile}
821
+ pkg={pkg}
804
822
  />
805
823
  <div className="flex flex-col flex-1 w-full min-w-0 h-full">
806
824
  {showImportAndFormatButtons && (
@@ -189,13 +189,10 @@ export const CodeEditorHeader: React.FC<CodeEditorHeaderProps> = ({
189
189
  onError: (error) => {
190
190
  throw error
191
191
  },
192
- openFile: false,
193
192
  })
194
193
  if (!createFileResult.newFileCreated) {
195
- throw new Error("Failed to create file")
194
+ throw new Error("Failed to create component file")
196
195
  }
197
- const newContent = `import ${componentName.replace(/-/g, "")} from "./${componentName}.tsx"\n${files[currentFile || ""]}`
198
- updateFileContent(currentFile, newContent)
199
196
  }
200
197
  }
201
198
 
@@ -221,7 +218,15 @@ export const CodeEditorHeader: React.FC<CodeEditorHeaderProps> = ({
221
218
  sidebarOpen ? "-ml-2" : "-ml-1"
222
219
  }`}
223
220
  >
224
- <SelectValue placeholder="Select file" />
221
+ <SelectValue
222
+ placeholder={
223
+ Object.keys(files).filter(
224
+ (filename) => !isHiddenFile(filename),
225
+ ).length > 0
226
+ ? "Select file"
227
+ : "No files"
228
+ }
229
+ />
225
230
  </SelectTrigger>
226
231
  <SelectContent>
227
232
  {Object.keys(files)
@@ -307,12 +312,12 @@ export const CodeEditorHeader: React.FC<CodeEditorHeaderProps> = ({
307
312
  onClick={() => {
308
313
  setAiAutocompleteEnabled((prev) => !prev)
309
314
  }}
310
- className={`relative bg-transparent ${aiAutocompleteEnabled ? "text-gray-600 bg-gray-50" : "text-gray-400"}`}
315
+ className={`relative group bg-transparent ${aiAutocompleteEnabled ? "text-gray-600 bg-gray-50" : "text-gray-400"}`}
311
316
  >
312
317
  <Bot className="h-4 w-4" />
313
318
  {!aiAutocompleteEnabled && (
314
319
  <div className="absolute inset-0 flex items-center justify-center">
315
- <div className="w-5 h-0.5 bg-gray-400 rotate-45 rounded-full" />
320
+ <div className="w-5 h-0.5 group-hover:bg-slate-900 bg-gray-400 rotate-45 rounded-full" />
316
321
  </div>
317
322
  )}
318
323
  </Button>
@@ -3,6 +3,7 @@ import * as AccordionPrimitive from "@radix-ui/react-accordion"
3
3
  import { ChevronRight } from "lucide-react"
4
4
  import { cva } from "class-variance-authority"
5
5
  import { cn } from "@/lib/utils"
6
+ import { Input } from "@/components/ui/input"
6
7
 
7
8
  const treeVariants = cva(
8
9
  "group hover:before:opacity-100 before:absolute before:rounded-lg before:left-0 before:w-full before:opacity-0 before:bg-slate-100/70 before:h-[2rem] before:-z-10' dark:before:bg-slate-800/70",
@@ -18,7 +19,7 @@ const dragOverVariants = cva(
18
19
 
19
20
  interface TreeDataItem {
20
21
  id: string
21
- name: string
22
+ name: React.ReactNode
22
23
  icon?: any
23
24
  selectedIcon?: any
24
25
  openIcon?: any
@@ -27,6 +28,9 @@ interface TreeDataItem {
27
28
  onClick?: () => void
28
29
  draggable?: boolean
29
30
  droppable?: boolean
31
+ isRenaming?: boolean
32
+ onRename?: (newName: string) => void
33
+ onCancelRename?: () => void
30
34
  }
31
35
 
32
36
  type TreeProps = React.HTMLAttributes<HTMLDivElement> & {
@@ -403,7 +407,52 @@ const TreeLeaf = React.forwardRef<
403
407
  isSelected={selectedItemId === item.id}
404
408
  default={defaultLeafIcon}
405
409
  />
406
- <span className="flex-grow text-sm truncate">{item.name}</span>
410
+ {item.isRenaming ? (
411
+ <Input
412
+ style={{
413
+ zIndex: 50,
414
+ }}
415
+ defaultValue={item.name as string}
416
+ onKeyDown={(e) => {
417
+ if (e.key === "Enter") {
418
+ e.preventDefault()
419
+ const value = e.currentTarget.value.trim()
420
+ if (value && value !== item.name) {
421
+ item.onRename?.(value)
422
+ } else {
423
+ item.onCancelRename?.()
424
+ }
425
+ } else if (e.key === "Escape") {
426
+ e.preventDefault()
427
+ item.onCancelRename?.()
428
+ }
429
+ }}
430
+ spellCheck={false}
431
+ autoComplete="off"
432
+ onBlur={(e) => {
433
+ const value = e.currentTarget.value.trim()
434
+ if (value && value !== item.name) {
435
+ item.onRename?.(value)
436
+ } else {
437
+ item.onCancelRename?.()
438
+ }
439
+ }}
440
+ autoFocus
441
+ onClick={(e) => e.stopPropagation()}
442
+ className="h-6 px-2 py-0 text-sm flex-1 mr-8 bg-white border border-blue-500 rounded-sm focus:outline-none focus:ring-1 focus:ring-blue-500 focus:border-blue-500 shadow-sm"
443
+ onFocus={(e) => {
444
+ e.currentTarget.select()
445
+ // Select filename without extension
446
+ const filename = e.currentTarget.value
447
+ const lastDotIndex = filename.lastIndexOf(".")
448
+ if (lastDotIndex > 0) {
449
+ e.currentTarget.setSelectionRange(0, lastDotIndex)
450
+ }
451
+ }}
452
+ />
453
+ ) : (
454
+ <span className="text-sm truncate">{item.name}</span>
455
+ )}
407
456
  <div className="flex items-center" onClick={(e) => e.stopPropagation()}>
408
457
  <TreeActions isSelected={true}>{item.actions}</TreeActions>
409
458
  </div>
@@ -17,6 +17,7 @@ import { useCreatePackageReleaseMutation } from "./use-create-package-release-mu
17
17
  import { useCreatePackageMutation } from "./use-create-package-mutation"
18
18
  import { findTargetFile } from "@/lib/utils/findTargetFile"
19
19
  import { encodeFsMapToUrlHash } from "@/lib/encodeFsMapToUrlHash"
20
+ import { isHiddenFile } from "@/components/ViewPackagePage/utils/is-hidden-file"
20
21
 
21
22
  export interface ICreateFileProps {
22
23
  newFileName: string
@@ -36,6 +37,16 @@ export interface IDeleteFileProps {
36
37
  onError: (error: Error) => void
37
38
  }
38
39
 
40
+ export interface IRenameFileProps {
41
+ oldFilename: string
42
+ newFilename: string
43
+ onError: (error: Error) => void
44
+ }
45
+
46
+ export interface IRenameFileResult {
47
+ fileRenamed: boolean
48
+ }
49
+
39
50
  export function useFileManagement({
40
51
  templateCode,
41
52
  currentPackage,
@@ -234,12 +245,69 @@ export function useFileManagement({
234
245
  }
235
246
  const updatedFiles = localFiles.filter((file) => file.path !== filename)
236
247
  setLocalFiles(updatedFiles)
237
- onFileSelect(updatedFiles[0]?.path || "")
248
+ onFileSelect(
249
+ updatedFiles.filter((file) => !isHiddenFile(file.path))[0]?.path || "",
250
+ )
238
251
  return {
239
252
  fileDeleted: true,
240
253
  }
241
254
  }
242
255
 
256
+ const renameFile = ({
257
+ oldFilename,
258
+ newFilename,
259
+ onError,
260
+ }: IRenameFileProps): IRenameFileResult => {
261
+ newFilename = newFilename.trim()
262
+ if (!newFilename) {
263
+ onError(new Error("File name cannot be empty"))
264
+ return {
265
+ fileRenamed: false,
266
+ }
267
+ }
268
+
269
+ // Extract just the filename from the path for validation
270
+ const fileNameOnly = newFilename.split("/").pop() || ""
271
+ if (!isValidFileName(fileNameOnly)) {
272
+ onError(new Error("Invalid file name"))
273
+ return {
274
+ fileRenamed: false,
275
+ }
276
+ }
277
+
278
+ const oldFileExists = localFiles?.some((file) => file.path === oldFilename)
279
+ if (!oldFileExists) {
280
+ onError(new Error("File does not exist"))
281
+ return {
282
+ fileRenamed: false,
283
+ }
284
+ }
285
+
286
+ const newFileExists = localFiles?.some((file) => file.path === newFilename)
287
+ if (newFileExists) {
288
+ onError(new Error("A file with this name already exists"))
289
+ return {
290
+ fileRenamed: false,
291
+ }
292
+ }
293
+
294
+ const updatedFiles = localFiles.map((file) => {
295
+ if (file.path === oldFilename) {
296
+ return { ...file, path: newFilename }
297
+ }
298
+ return file
299
+ })
300
+
301
+ setLocalFiles(updatedFiles)
302
+ if (currentFile === oldFilename) {
303
+ setCurrentFile(newFilename)
304
+ }
305
+
306
+ return {
307
+ fileRenamed: true,
308
+ }
309
+ }
310
+
243
311
  const savePackage = async (isPrivate: boolean) => {
244
312
  if (!isLoggedIn) {
245
313
  toast({
@@ -354,6 +422,7 @@ export function useFileManagement({
354
422
  fsMap,
355
423
  createFile,
356
424
  deleteFile,
425
+ renameFile,
357
426
  saveFiles,
358
427
  localFiles,
359
428
  initialFiles,
@@ -0,0 +1,13 @@
1
+ import { AnyCircuitElement } from "circuit-json"
2
+ import { saveAs } from "file-saver"
3
+ import { circuitJsonToSpice } from "circuit-json-to-spice"
4
+
5
+ export const downloadSpiceFile = (
6
+ circuitJson: AnyCircuitElement[],
7
+ fileName: string,
8
+ ) => {
9
+ const spiceNetlist = circuitJsonToSpice(circuitJson)
10
+ const spiceString = spiceNetlist.toSpiceString()
11
+ const blob = new Blob([spiceString], { type: "text/plain" })
12
+ saveAs(blob, fileName + ".cir")
13
+ }
@@ -98,7 +98,7 @@ export const DashboardPage = () => {
98
98
  <title>Dashboard - tscircuit</title>
99
99
  </Helmet>
100
100
  <Header />
101
- <div className="container mx-auto px-4 py-8">
101
+ <div className="container mx-auto px-4 py-8 min-h-[80vh]">
102
102
  <h1 className="text-3xl font-bold mb-6">Dashboard</h1>
103
103
  <div className="flex md:flex-row flex-col">
104
104
  <div className="md:w-3/4 p-0 md:pr-6">