Skip to content

Commit 8a904f1

Browse files
committed
feat(files): sync an untitled file's name with its leading heading
While a file is still named untitled(.md), typing a leading heading auto-renames the file after it (debounced), and renaming the file first seeds a leading H1 from the new name. One-shot: coupling stops once the file has a real name, and the heading seed always prepends so existing content is never clobbered.
1 parent 2573a64 commit 8a904f1

7 files changed

Lines changed: 400 additions & 6 deletions

File tree

apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/file-viewer.tsx

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -116,6 +116,11 @@ interface FileViewerProps {
116116
* never target one editor. See {@link RichMarkdownEditorProps.collaborative}.
117117
*/
118118
collaborative?: boolean
119+
/**
120+
* Called (debounced) with the markdown document's leading-heading text while the file is still
121+
* untitled, so the caller can name the file after it. Only wired for the editable markdown editor.
122+
*/
123+
onDeriveTitleFromHeading?: (headingText: string) => void
119124
}
120125

121126
export function FileViewer(props: FileViewerProps) {
@@ -148,6 +153,7 @@ function FileViewerContent({
148153
disableStreamingAutoScroll = false,
149154
previewContextKey,
150155
collaborative,
156+
onDeriveTitleFromHeading,
151157
}: FileViewerProps) {
152158
const category = resolveFileCategory(file.type, file.name)
153159

@@ -193,6 +199,7 @@ function FileViewerContent({
193199
disableStreamingAutoScroll={disableStreamingAutoScroll}
194200
previewContextKey={previewContextKey}
195201
collaborative={collaborative}
202+
onDeriveTitleFromHeading={onDeriveTitleFromHeading}
196203
/>
197204
)
198205
}

apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-editor.tsx

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,10 @@ import { useRouter } from 'next/navigation'
1313
import { useSession } from '@/lib/auth/auth-client'
1414
import type { WorkspaceFileRecord } from '@/lib/uploads/contexts/workspace'
1515
import { extractEmbeddedFileRef } from '@/lib/uploads/utils/embedded-image-ref'
16+
import {
17+
headingTextFromName,
18+
isUntitledName,
19+
} from '@/app/workspace/[workspaceId]/files/untitled-title'
1620
import { useUploadWorkspaceFile } from '@/hooks/queries/workspace-files'
1721
import type { SaveStatus } from '@/hooks/use-autosave'
1822
import { useFileContentSource } from '@/hooks/use-file-content-source'
@@ -41,6 +45,7 @@ import { LinkHoverCard } from './menus/link-hover-card'
4145
import { TableBubbleMenu } from './menus/table-menu'
4246
import { normalizeMarkdownContent } from './normalize-content'
4347
import { isRoundTripSafe } from './round-trip-safety'
48+
import { firstHeadingTitle, titleHeadingNode } from './title-heading'
4449
import '@sim/emcn/components/code/code.css'
4550
import './rich-markdown-editor.css'
4651

@@ -55,6 +60,9 @@ const EXTENSIONS = createMarkdownEditorExtensions({
5560
const STREAM_REPARSE_THROTTLE_THRESHOLD = 40_000
5661
const STREAM_REPARSE_THROTTLE_MS = 120
5762

63+
/** Debounce before naming a still-untitled file after its leading heading, so it fires once typing settles. */
64+
const DERIVE_TITLE_DEBOUNCE_MS = 600
65+
5866
interface RichMarkdownEditorProps {
5967
file: WorkspaceFileRecord
6068
workspaceId: string
@@ -83,6 +91,12 @@ interface RichMarkdownEditorProps {
8391
* construction (they cannot both drive one editor and corrupt the shared doc).
8492
*/
8593
collaborative?: boolean
94+
/**
95+
* Called (debounced) with the document's leading-heading text while the file is still untitled, so the
96+
* caller can name the file after it. Omitted on read-only/non-editable surfaces. See
97+
* {@link isUntitledName}.
98+
*/
99+
onDeriveTitleFromHeading?: (headingText: string) => void
86100
}
87101

88102
/** Inline WYSIWYG markdown editor: agent output streams in read-only, then the same instance becomes editable on settle. */
@@ -102,6 +116,7 @@ export const RichMarkdownEditor = memo(function RichMarkdownEditor({
102116
previewContextKey,
103117
disableTagging,
104118
collaborative = false,
119+
onDeriveTitleFromHeading,
105120
}: RichMarkdownEditorProps) {
106121
const { data: session, isPending: isSessionPending } = useSession()
107122
const userId = session?.user?.id ?? ''
@@ -168,6 +183,7 @@ export const RichMarkdownEditor = memo(function RichMarkdownEditor({
168183
onChange={setDraftContent}
169184
onSaveShortcut={saveImmediately}
170185
onCollabReadyChange={setCollabReady}
186+
onDeriveTitleFromHeading={onDeriveTitleFromHeading}
171187
/>
172188
)
173189
})
@@ -194,6 +210,8 @@ interface LoadedRichMarkdownEditorProps {
194210
onSaveShortcut: () => Promise<void>
195211
/** Reports whether the collaborative document is synced+seeded (autosave gate). */
196212
onCollabReadyChange: (ready: boolean) => void
213+
/** See {@link RichMarkdownEditorProps.onDeriveTitleFromHeading}. */
214+
onDeriveTitleFromHeading?: (headingText: string) => void
197215
}
198216

199217
interface SettledContent {
@@ -223,6 +241,7 @@ export function LoadedRichMarkdownEditor({
223241
onChange,
224242
onSaveShortcut,
225243
onCollabReadyChange,
244+
onDeriveTitleFromHeading,
226245
}: LoadedRichMarkdownEditorProps) {
227246
/** Whether this editor mounted mid-stream — if so it starts empty and syncs streamed chunks until settle. */
228247
const streamingAtMountRef = useRef(isStreaming)
@@ -288,6 +307,19 @@ export function LoadedRichMarkdownEditor({
288307
onChangeRef.current = onChange
289308
const onSaveShortcutRef = useRef(onSaveShortcut)
290309
onSaveShortcutRef.current = onSaveShortcut
310+
311+
/**
312+
* Untitled ⇄ heading coupling, active only while the file is unnamed. `onDeriveTitleFromHeading` is
313+
* called (debounced) so the caller can name the file after its leading heading; `fileNameRef` lets the
314+
* onUpdate handler read the current name without re-subscribing, and `prevFileNameRef` lets the
315+
* name→heading seed detect the untitled→named transition. See {@link isUntitledName}.
316+
*/
317+
const onDeriveTitleFromHeadingRef = useRef(onDeriveTitleFromHeading)
318+
onDeriveTitleFromHeadingRef.current = onDeriveTitleFromHeading
319+
const fileNameRef = useRef(file.name)
320+
fileNameRef.current = file.name
321+
const prevFileNameRef = useRef(file.name)
322+
const deriveTitleTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
291323
/**
292324
* Read in the RAF tick so an already-scheduled tick still sees the latest edit kind (it can change
293325
* between sessions within one turn, e.g. an append followed by a rewrite).
@@ -546,10 +578,52 @@ export function LoadedRichMarkdownEditor({
546578
const md = postProcessSerializedMarkdown(editor.getMarkdown())
547579
lastSyncedBodyRef.current = md
548580
onChangeRef.current(applyFrontmatter(settledRef.current?.frontmatter ?? '', md))
581+
// While the file is still untitled, name it after its leading heading once typing settles. Always
582+
// clear any pending timer first, so deleting/rewriting the heading before it fires cancels the
583+
// stale rename; the timer re-derives the title from the live doc rather than a value captured at
584+
// schedule time, so it can never name the file after a heading the user has since changed or removed.
585+
if (deriveTitleTimerRef.current) clearTimeout(deriveTitleTimerRef.current)
586+
if (!isUntitledName(fileNameRef.current) || firstHeadingTitle(editor.state.doc) === null)
587+
return
588+
deriveTitleTimerRef.current = setTimeout(() => {
589+
const liveEditor = editorInstanceRef.current
590+
if (!liveEditor || !isUntitledName(fileNameRef.current)) return
591+
const title = firstHeadingTitle(liveEditor.state.doc)
592+
if (title) onDeriveTitleFromHeadingRef.current?.(title)
593+
}, DERIVE_TITLE_DEBOUNCE_MS)
549594
},
550595
})
551596
editorInstanceRef.current = editor
552597

598+
useEffect(
599+
() => () => {
600+
if (deriveTitleTimerRef.current) clearTimeout(deriveTitleTimerRef.current)
601+
},
602+
[]
603+
)
604+
605+
/**
606+
* Name→heading seed: when a still-untitled file is given a real name, seed the document's leading
607+
* heading from that name — but only into a document with no heading of its own yet, so existing
608+
* content is never clobbered. One-shot by construction: it fires only on the untitled→named transition.
609+
*/
610+
useEffect(() => {
611+
// Wait for a render with a live, editable editor before consuming the transition — advancing
612+
// prevFileNameRef while editor is still null would record the untitled→named change as "seen" and
613+
// silently skip the seed on the later render when the editor is ready.
614+
if (!editor || !isEditable) return
615+
const prevName = prevFileNameRef.current
616+
prevFileNameRef.current = file.name
617+
if (!isUntitledName(prevName) || isUntitledName(file.name)) return
618+
if (firstHeadingTitle(editor.state.doc) !== null) return
619+
const title = headingTextFromName(file.name)
620+
if (!title) return
621+
// Always prepend, never replace: an empty doc becomes `# Title` + a body line, while any existing
622+
// content — including structure-only scaffold (an empty list/blockquote/code block that carries no
623+
// text) — is preserved above rather than clobbered.
624+
editor.commands.insertContentAt(0, titleHeadingNode(title))
625+
}, [file.name, editor, isEditable])
626+
553627
/**
554628
* The loaded markdown to seed the shared doc from, held by pointer so the parse
555629
* runs once at seed time rather than every render.
Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
/**
2+
* @vitest-environment jsdom
3+
*/
4+
import { Editor } from '@tiptap/core'
5+
import { beforeEach, describe, expect, it, vi } from 'vitest'
6+
import { createMarkdownEditorExtensions } from './editor-extensions'
7+
import { firstHeadingTitle, titleHeadingNode } from './title-heading'
8+
9+
function editorWith(markdown: string): Editor {
10+
const editor = new Editor({ extensions: createMarkdownEditorExtensions({ placeholder: '' }) })
11+
if (markdown) editor.commands.setContent(markdown, { contentType: 'markdown' })
12+
return editor
13+
}
14+
15+
describe('firstHeadingTitle', () => {
16+
beforeEach(() => {
17+
vi.stubGlobal(
18+
'ResizeObserver',
19+
class {
20+
observe() {}
21+
unobserve() {}
22+
disconnect() {}
23+
}
24+
)
25+
Element.prototype.scrollIntoView = vi.fn()
26+
document.elementFromPoint = vi.fn(() => null)
27+
})
28+
29+
it('returns the leading H1 text', () => {
30+
const editor = editorWith('# Q3 Planning\n\nbody')
31+
expect(firstHeadingTitle(editor.state.doc)).toBe('Q3 Planning')
32+
editor.destroy()
33+
})
34+
35+
it('returns the text of any leading heading level', () => {
36+
const editor = editorWith('## Sub title')
37+
expect(firstHeadingTitle(editor.state.doc)).toBe('Sub title')
38+
editor.destroy()
39+
})
40+
41+
it('returns null when the first block is a paragraph, not a heading', () => {
42+
const editor = editorWith('just text\n\n# later heading')
43+
expect(firstHeadingTitle(editor.state.doc)).toBeNull()
44+
editor.destroy()
45+
})
46+
47+
it('returns null for an empty leading heading', () => {
48+
const editor = editorWith('')
49+
editor.commands.setContent({ type: 'doc', content: [{ type: 'heading', attrs: { level: 1 } }] })
50+
expect(firstHeadingTitle(editor.state.doc)).toBeNull()
51+
editor.destroy()
52+
})
53+
54+
it('returns null for a whitespace-only leading heading (trim boundary)', () => {
55+
const editor = editorWith('')
56+
editor.commands.setContent({
57+
type: 'doc',
58+
content: [{ type: 'heading', attrs: { level: 1 }, content: [{ type: 'text', text: ' ' }] }],
59+
})
60+
expect(firstHeadingTitle(editor.state.doc)).toBeNull()
61+
editor.destroy()
62+
})
63+
})
64+
65+
describe('title-heading seed (Trigger B action — always prepend, never replace)', () => {
66+
beforeEach(() => {
67+
vi.stubGlobal(
68+
'ResizeObserver',
69+
class {
70+
observe() {}
71+
unobserve() {}
72+
disconnect() {}
73+
}
74+
)
75+
Element.prototype.scrollIntoView = vi.fn()
76+
document.elementFromPoint = vi.fn(() => null)
77+
})
78+
79+
it('seeds an empty doc with a leading H1 (+ a body line)', () => {
80+
const editor = editorWith('')
81+
editor.commands.insertContentAt(0, titleHeadingNode('Q3 Planning'))
82+
83+
expect(editor.state.doc.firstChild?.type.name).toBe('heading')
84+
expect(firstHeadingTitle(editor.state.doc)).toBe('Q3 Planning')
85+
expect(editor.getMarkdown().trim()).toBe('# Q3 Planning')
86+
editor.destroy()
87+
})
88+
89+
it('prepends an H1 to existing body without a heading, keeping the body', () => {
90+
const editor = editorWith('some body text')
91+
editor.commands.insertContentAt(0, titleHeadingNode('My Notes'))
92+
93+
expect(editor.state.doc.firstChild?.type.name).toBe('heading')
94+
expect(firstHeadingTitle(editor.state.doc)).toBe('My Notes')
95+
expect(editor.getMarkdown()).toContain('some body text')
96+
editor.destroy()
97+
})
98+
99+
it('preserves structure-only scaffold (empty bullet list) instead of clobbering it', () => {
100+
// Regression: an empty bullet list carries no text, so a replace-based seed would drop it. The
101+
// prepend keeps it below the seeded H1.
102+
const editor = editorWith('')
103+
editor.commands.setContent({
104+
type: 'doc',
105+
content: [
106+
{ type: 'bulletList', content: [{ type: 'listItem', content: [{ type: 'paragraph' }] }] },
107+
],
108+
})
109+
editor.commands.insertContentAt(0, titleHeadingNode('Kept'))
110+
111+
const shape: string[] = []
112+
editor.state.doc.forEach((n) => shape.push(n.type.name))
113+
expect(shape).toEqual(['heading', 'bulletList', 'paragraph'])
114+
expect(firstHeadingTitle(editor.state.doc)).toBe('Kept')
115+
editor.destroy()
116+
})
117+
})
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
import type { JSONContent } from '@tiptap/core'
2+
import type { Node as ProseMirrorNode } from '@tiptap/pm/model'
3+
4+
/** The text of the document's leading heading (any level), or null when the first block isn't a heading. */
5+
export function firstHeadingTitle(doc: ProseMirrorNode): string | null {
6+
const first = doc.firstChild
7+
if (!first || first.type.name !== 'heading') return null
8+
const text = first.textContent.trim()
9+
return text.length > 0 ? text : null
10+
}
11+
12+
/** A level-1 heading node carrying `title`, used to seed a document's title from the file name. */
13+
export function titleHeadingNode(title: string): JSONContent {
14+
return { type: 'heading', attrs: { level: 1 }, content: [{ type: 'text', text: title }] }
15+
}

apps/sim/app/workspace/[workspaceId]/files/files.tsx

Lines changed: 36 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,12 @@ import {
8585
filesSortParams,
8686
filesUrlKeys,
8787
} from '@/app/workspace/[workspaceId]/files/search-params'
88+
import {
89+
DEFAULT_UNTITLED_NAME,
90+
deriveMarkdownFileName,
91+
isUntitledName,
92+
uniqueMarkdownName,
93+
} from '@/app/workspace/[workspaceId]/files/untitled-title'
8894
import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
8995
import { useContextMenu } from '@/app/workspace/[workspaceId]/w/components/sidebar/hooks'
9096
import { useWorkspaceMembersQuery, type WorkspaceMember } from '@/hooks/queries/workspace'
@@ -356,6 +362,34 @@ export function Files() {
356362
const selectedFileRef = useRef(selectedFile)
357363
selectedFileRef.current = selectedFile
358364

365+
/**
366+
* While a file is still untitled, name it after the leading heading the user types in its editor. The
367+
* editor reports the heading text (debounced); here we re-check the file is still untitled, derive a
368+
* unique `.md` name among its folder siblings, and rename. A no-op once the file has a real name.
369+
*/
370+
const handleDeriveTitleFromHeading = useCallback(
371+
(headingText: string) => {
372+
const currentFile = selectedFileRef.current
373+
if (!currentFile || !isUntitledName(currentFile.name)) return
374+
const derived = deriveMarkdownFileName(headingText)
375+
if (!derived) return
376+
const siblingNames = new Set(
377+
filesRef.current
378+
.filter(
379+
(f) =>
380+
(f.folderId ?? null) === (currentFile.folderId ?? null) && f.id !== currentFile.id
381+
)
382+
.map((f) => f.name)
383+
)
384+
const name = uniqueMarkdownName(derived, siblingNames)
385+
if (name === currentFile.name) return
386+
renameFile
387+
.mutateAsync({ workspaceId, fileId: currentFile.id, name })
388+
.catch((err) => logger.error('Failed to auto-name file from heading:', err))
389+
},
390+
[workspaceId]
391+
)
392+
359393
const shareFile = shareFileId ? (files.find((f) => f.id === shareFileId) ?? null) : null
360394
const shareModal = shareFile ? (
361395
<ShareModal
@@ -1189,12 +1223,7 @@ export function Files() {
11891223
const existingNames = new Set(
11901224
filesRef.current.filter((f) => (f.folderId ?? null) === currentFolderId).map((f) => f.name)
11911225
)
1192-
let name = 'untitled.md'
1193-
let counter = 1
1194-
while (existingNames.has(name)) {
1195-
name = `untitled (${counter}).md`
1196-
counter++
1197-
}
1226+
const name = uniqueMarkdownName(DEFAULT_UNTITLED_NAME, existingNames)
11981227

11991228
const mimeType = getMimeTypeFromExtension('md')
12001229
const blob = new Blob([''], { type: mimeType })
@@ -1931,6 +1960,7 @@ export function Files() {
19311960
saveRef={saveRef}
19321961
discardRef={discardRef}
19331962
collaborative
1963+
onDeriveTitleFromHeading={handleDeriveTitleFromHeading}
19341964
/>
19351965

19361966
<ChipConfirmModal

0 commit comments

Comments
 (0)