diff --git a/src/components/Project/AddDocumentDialog.test.tsx b/src/components/Project/AddDocumentDialog.test.tsx index 91a630f9c..a69ad2d01 100644 --- a/src/components/Project/AddDocumentDialog.test.tsx +++ b/src/components/Project/AddDocumentDialog.test.tsx @@ -174,7 +174,9 @@ describe("AddDocumentDialog", () => { await act(async () => options.onSuccess()); expect(notify).toHaveBeenCalledWith("Document added", "success"); - expect(track).toHaveBeenCalledWith("projects.add_document_completed"); + expect(track).toHaveBeenCalledWith("projects.add_document_completed", { + kind: "document", + }); await waitFor(() => { expect(screen.queryByRole("dialog")).toBeNull(); }); diff --git a/src/components/Project/AddDocumentDialog.tsx b/src/components/Project/AddDocumentDialog.tsx index bed3e3b01..b10346aa0 100644 --- a/src/components/Project/AddDocumentDialog.tsx +++ b/src/components/Project/AddDocumentDialog.tsx @@ -8,21 +8,34 @@ import { } from "@/components/ui/dialog"; import useToastNotification from "@/hooks/useToastNotification"; import { useAnalytics } from "@/providers/AnalyticsProvider"; +import type { DocumentKind } from "@/services/projects/resourceDescriptor"; import type { CreateResourceInput } from "@/services/projects/types"; import { useCreateProjectResource } from "@/services/projects/useProjectResources"; import { DocumentForm } from "./DocumentForm"; +const TITLE: Record = { + document: "Add Document", + code: "Add Code", +}; + +const ADDED: Record = { + document: "Document added", + code: "Code added", +}; + interface AddDocumentDialogProps { projectId: string; open: boolean; onOpenChange: (open: boolean) => void; + kind?: DocumentKind; } export function AddDocumentDialog({ projectId, open, onOpenChange, + kind = "document", }: AddDocumentDialogProps) { const createResource = useCreateProjectResource(projectId); const notify = useToastNotification(); @@ -30,15 +43,15 @@ export function AddDocumentDialog({ useEffect(() => { if (open) { - track("projects.add_document_dialog_impression"); + track("projects.add_document_dialog_impression", { kind }); } - }, [open, track]); + }, [open, track, kind]); const handleSubmit = (input: CreateResourceInput) => { createResource.mutate(input, { onSuccess: () => { - track("projects.add_document_completed"); - notify("Document added", "success"); + track("projects.add_document_completed", { kind }); + notify(ADDED[kind], "success"); onOpenChange(false); }, }); @@ -48,12 +61,13 @@ export function AddDocumentDialog({ - Add Document + {TITLE[kind]} {/* Keyed on `open` so a dialog reopened after a cancel starts empty: the fields live in the form, which the dialog keeps mounted. */} onOpenChange(false)} isSubmitting={createResource.isPending} diff --git a/src/components/Project/AddResourceMenu.test.tsx b/src/components/Project/AddResourceMenu.test.tsx index 052179b6c..67a0d448f 100644 --- a/src/components/Project/AddResourceMenu.test.tsx +++ b/src/components/Project/AddResourceMenu.test.tsx @@ -49,6 +49,16 @@ describe("AddResourceMenu", () => { expect(await screen.findByRole("dialog")).toHaveTextContent("Add Document"); }); + it("opens the same form for code, under its own name", async () => { + const user = await openMenu(); + + await user.click(screen.getByRole("menuitem", { name: "Code" })); + + const dialog = await screen.findByRole("dialog"); + expect(dialog).toHaveTextContent("Add Code"); + expect(screen.getByLabelText("Filename")).toBeInTheDocument(); + }); + it("opens the pipeline picker", async () => { await openMenu(); diff --git a/src/components/Project/AddResourceMenu.tsx b/src/components/Project/AddResourceMenu.tsx index 1472a0849..98613a304 100644 --- a/src/components/Project/AddResourceMenu.tsx +++ b/src/components/Project/AddResourceMenu.tsx @@ -8,6 +8,8 @@ import { DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; import { Icon } from "@/components/ui/icon"; +import type { DocumentKind } from "@/services/projects/resourceDescriptor"; +import { resourceMeta } from "@/services/projects/resourceMeta"; import type { ProjectResourceSummary } from "@/services/projects/types"; import { tracking } from "@/utils/tracking"; @@ -25,8 +27,14 @@ export function AddResourceMenu({ resources, }: AddResourceMenuProps) { const [documentOpen, setDocumentOpen] = useState(false); + const [documentKind, setDocumentKind] = useState("document"); const [pipelineOpen, setPipelineOpen] = useState(false); + const openDocument = (kind: DocumentKind) => { + setDocumentKind(kind); + setDocumentOpen(true); + }; + return ( <> @@ -43,13 +51,21 @@ export function AddResourceMenu({ setDocumentOpen(true)} - {...tracking("projects.add_document_open")} + onSelect={() => openDocument("document")} + {...tracking("projects.add_document_open", { kind: "document" })} > Document + openDocument("code")} + {...tracking("projects.add_document_open", { kind: "code" })} + > + + Code + + setPipelineOpen(true)} {...tracking("projects.add_pipeline_open")} @@ -64,6 +80,7 @@ export function AddResourceMenu({ projectId={projectId} open={documentOpen} onOpenChange={setDocumentOpen} + kind={documentKind} /> {/* Mounted only while open so the project page does not read browser diff --git a/src/components/Project/DocumentEditor.test.tsx b/src/components/Project/DocumentEditor.test.tsx index b17c3f1a6..faf7b9d18 100644 --- a/src/components/Project/DocumentEditor.test.tsx +++ b/src/components/Project/DocumentEditor.test.tsx @@ -229,6 +229,22 @@ describe("DocumentEditor", () => { expect(screen.queryByRole("alert")).toBeNull(); }); + it("reports saving code under the same event as a document, told apart by kind", () => { + renderEditor( + resource({ + metadata: { type: "code" }, + payload: { content: "print(1)" }, + }), + ); + + const save = screen.getByRole("button", { name: "Save" }); + expect(save).toHaveAttribute("data-tracking-id", "projects.save_document"); + expect(save).toHaveAttribute( + "data-tracking-metadata", + JSON.stringify({ kind: "code" }), + ); + }); + it("opens an empty document ready to be written into", async () => { renderEditor(resource({ name: null, payload: {} })); diff --git a/src/components/Project/DocumentEditor.tsx b/src/components/Project/DocumentEditor.tsx index 482d9b3f7..5bfa03f7c 100644 --- a/src/components/Project/DocumentEditor.tsx +++ b/src/components/Project/DocumentEditor.tsx @@ -8,6 +8,7 @@ import { Icon } from "@/components/ui/icon"; import { BlockStack, InlineStack } from "@/components/ui/layout"; import { Text } from "@/components/ui/typography"; import { UNTITLED } from "@/services/projects/placeholderNames"; +import { documentKindOf } from "@/services/projects/resourceDescriptor"; import { resourceMeta } from "@/services/projects/resourceMeta"; import type { ProjectResource } from "@/services/projects/types"; import { useUpdateProjectResource } from "@/services/projects/useProjectResources"; @@ -42,6 +43,7 @@ export function DocumentEditor({ projectId, resource }: DocumentEditorProps) { const changedElsewhere = edit !== null && edit.startedFrom !== saved; const canSave = isDirty && !update.isPending; const name = resource.name ?? UNTITLED; + const kind = documentKindOf(resource); const handleChange = (value: string) => { setEdit((current) => ({ @@ -84,7 +86,7 @@ export function DocumentEditor({ projectId, resource }: DocumentEditorProps) { className="w-full shrink-0 border-b border-border px-3 py-1.5" > @@ -102,7 +104,7 @@ export function DocumentEditor({ projectId, resource }: DocumentEditorProps) { size="xs" disabled={edit === null || update.isPending} onClick={discard} - {...tracking("projects.discard_document_edits")} + {...tracking("projects.discard_document_edits", { kind })} > Discard @@ -110,7 +112,7 @@ export function DocumentEditor({ projectId, resource }: DocumentEditorProps) { size="xs" disabled={!canSave} onClick={save} - {...tracking("projects.save_document")} + {...tracking("projects.save_document", { kind })} > {update.isPending ? "Saving…" : "Save"} diff --git a/src/components/Project/DocumentForm.test.tsx b/src/components/Project/DocumentForm.test.tsx index a9389649a..e74825b8c 100644 --- a/src/components/Project/DocumentForm.test.tsx +++ b/src/components/Project/DocumentForm.test.tsx @@ -8,6 +8,8 @@ import { import userEvent from "@testing-library/user-event"; import { afterEach, describe, expect, it, vi } from "vitest"; +import type { DocumentKind } from "@/services/projects/resourceDescriptor"; + import { DocumentForm } from "./DocumentForm"; const track = vi.fn(); @@ -43,12 +45,21 @@ const form = () => titleField().closest("form") as HTMLFormElement; const draggingHint = () => screen.queryByText("Drop to fill in the title and content"); -function renderForm({ isSubmitting = false } = {}) { +function renderForm({ + isSubmitting = false, + kind, + onSubmit = vi.fn(), +}: { + isSubmitting?: boolean; + kind?: DocumentKind; + onSubmit?: (input: unknown) => void; +} = {}) { render( , ); } @@ -88,7 +99,7 @@ describe("DocumentForm file drop", () => { expect(titleField()).toHaveValue("model-card.md"); expect(track).toHaveBeenCalledWith( "projects.add_document_from_file_completed", - { method: "drop" }, + { method: "drop", kind: "document" }, ); }); @@ -114,7 +125,7 @@ describe("DocumentForm file drop", () => { expect(titleField()).toHaveValue("model-card.md"); expect(track).toHaveBeenCalledWith( "projects.add_document_from_file_completed", - { method: "browse" }, + { method: "browse", kind: "document" }, ); }); @@ -240,3 +251,63 @@ describe("DocumentForm file drop", () => { expect(draggingHint()).toBeNull(); }); }); + +describe("DocumentForm kinds", () => { + afterEach(() => { + vi.resetAllMocks(); + }); + + it("adds a document by default, filed under its title", async () => { + const onSubmit = vi.fn(); + const user = userEvent.setup(); + renderForm({ onSubmit }); + + await user.type(titleField(), "Model card"); + await user.type(contentField(), "Trained on Q3"); + await user.click(addButton()); + + expect(onSubmit).toHaveBeenCalledWith( + expect.objectContaining({ + name: "Model card", + metadata: { type: "document" }, + }), + ); + }); + + it("adds code by its filename, filed as code", async () => { + const onSubmit = vi.fn(); + const user = userEvent.setup(); + renderForm({ kind: "code", onSubmit }); + + const filename = screen.getByLabelText("Filename"); + expect(filename).toHaveAttribute("placeholder", "train.py"); + expect(screen.queryByLabelText("Title")).toBeNull(); + + await user.type(filename, "train.py"); + await user.type(contentField(), "print(1)"); + expect(contentField()).toHaveAttribute("data-language", "python"); + + await user.click(addButton()); + + expect(onSubmit).toHaveBeenCalledWith({ + entity: "document", + name: "train.py", + payload: { content: "print(1)" }, + metadata: { type: "code" }, + }); + }); + + it("says which kind a dropped file became", async () => { + renderForm({ kind: "code" }); + + fireEvent.drop(contentField(), fileDrag(modelCard())); + + await waitFor(() => { + expect(contentField()).toHaveValue("Trained on Q3 data"); + }); + expect(track).toHaveBeenCalledWith( + "projects.add_document_from_file_completed", + { method: "drop", kind: "code" }, + ); + }); +}); diff --git a/src/components/Project/DocumentForm.tsx b/src/components/Project/DocumentForm.tsx index 1e49f0054..c540faadf 100644 --- a/src/components/Project/DocumentForm.tsx +++ b/src/components/Project/DocumentForm.tsx @@ -8,7 +8,10 @@ import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { BlockStack, InlineStack } from "@/components/ui/layout"; import { useAnalytics } from "@/providers/AnalyticsProvider"; -import { documentResourceInput } from "@/services/projects/resourceDescriptor"; +import { + type DocumentKind, + documentResourceInput, +} from "@/services/projects/resourceDescriptor"; import type { CreateResourceInput } from "@/services/projects/types"; import { tracking } from "@/utils/tracking"; @@ -16,17 +19,39 @@ import { readDocumentFile } from "./documentFile"; import { DocumentFileDropZone } from "./DocumentFileDropZone"; import { useFileDrop } from "./useFileDrop"; +interface KindCopy { + nameLabel: string; + namePlaceholder: string; + contentPlaceholder: string; +} + +const KIND_COPY: Record = { + document: { + nameLabel: "Title", + namePlaceholder: "model-card.md", + contentPlaceholder: "What this document records", + }, + code: { + nameLabel: "Filename", + namePlaceholder: "train.py", + contentPlaceholder: "The source code", + }, +}; + interface DocumentFormProps { onSubmit: (input: CreateResourceInput) => void; onCancel: () => void; isSubmitting?: boolean; + kind?: DocumentKind; } export function DocumentForm({ onSubmit, onCancel, isSubmitting = false, + kind = "document", }: DocumentFormProps) { + const copy = KIND_COPY[kind]; const { track } = useAnalytics(); const [title, setTitle] = useState(""); const [content, setContent] = useState(""); @@ -57,7 +82,7 @@ export function DocumentForm({ ); setTitleFromFile(result.title); setContent(result.content); - track("projects.add_document_from_file_completed", { method }); + track("projects.add_document_from_file_completed", { method, kind }); }; const { isDraggingFile, dropTargetProps } = useFileDrop( @@ -75,7 +100,7 @@ export function DocumentForm({ const handleSubmit = (event: FormEvent) => { event.preventDefault(); if (!canSubmit) return; - onSubmit(documentResourceInput(trimmedTitle, trimmedContent)); + onSubmit(documentResourceInput(kind, trimmedTitle, trimmedContent)); }; return ( @@ -89,12 +114,12 @@ export function DocumentForm({ /> - + setTitle(event.target.value)} - placeholder="model-card.md" + placeholder={copy.namePlaceholder} autoFocus /> @@ -108,7 +133,7 @@ export function DocumentForm({ onChange={setContent} options={{ ariaLabel: "Content", - placeholder: "What this document records", + placeholder: copy.contentPlaceholder, }} /> @@ -120,14 +145,14 @@ export function DocumentForm({ type="button" variant="outline" onClick={onCancel} - {...tracking("projects.add_document_cancel")} + {...tracking("projects.add_document_cancel", { kind })} > Cancel diff --git a/src/components/shared/CodeViewer/languageFor.test.ts b/src/components/shared/CodeViewer/languageFor.test.ts new file mode 100644 index 000000000..fc50636a6 --- /dev/null +++ b/src/components/shared/CodeViewer/languageFor.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, it } from "vitest"; + +import { languageFor } from "./languageFor"; + +describe("languageFor", () => { + it.each([ + ["notes.md", "markdown"], + ["scripts/train.py", "python"], + ["run.sh", "shell"], + ["App.tsx", "typescript"], + ["Gemfile.rb", "ruby"], + ["main.go", "go"], + ["Dockerfile", "dockerfile"], + ["config.TOML", "ini"], + ["notebook.ipynb", "json"], + ])("maps %s to %s", (name, language) => { + expect(languageFor(name)).toBe(language); + }); + + it("reads anything it does not know as plain text", () => { + expect(languageFor("Model card")).toBe("plaintext"); + expect(languageFor("archive.tar.gz")).toBe("plaintext"); + expect(languageFor(null)).toBe("plaintext"); + expect(languageFor(undefined)).toBe("plaintext"); + }); +}); diff --git a/src/components/shared/CodeViewer/languageFor.ts b/src/components/shared/CodeViewer/languageFor.ts index 62ffb9455..98c5e6977 100644 --- a/src/components/shared/CodeViewer/languageFor.ts +++ b/src/components/shared/CodeViewer/languageFor.ts @@ -4,13 +4,48 @@ const LANGUAGE_BY_EXTENSION: Record = { md: "markdown", markdown: "markdown", json: "json", + ipynb: "json", yaml: "yaml", yml: "yaml", + toml: "ini", + ini: "ini", + cfg: "ini", py: "python", sh: "shell", + bash: "shell", + zsh: "shell", + ps1: "powershell", sql: "sql", ts: "typescript", + tsx: "typescript", js: "javascript", + jsx: "javascript", + mjs: "javascript", + rb: "ruby", + go: "go", + rs: "rust", + java: "java", + kt: "kotlin", + scala: "scala", + c: "c", + h: "c", + cpp: "cpp", + cc: "cpp", + hpp: "cpp", + cs: "csharp", + r: "r", + jl: "julia", + lua: "lua", + php: "php", + swift: "swift", + html: "html", + css: "css", + scss: "scss", + xml: "xml", + graphql: "graphql", + proto: "protobuf", + dockerfile: "dockerfile", + tf: "hcl", }; /** diff --git a/src/routes/v2/pages/Tangent/components/AddDocumentDialog.tsx b/src/routes/v2/pages/Tangent/components/AddDocumentDialog.tsx index 35dd7e918..ffbadb10c 100644 --- a/src/routes/v2/pages/Tangent/components/AddDocumentDialog.tsx +++ b/src/routes/v2/pages/Tangent/components/AddDocumentDialog.tsx @@ -5,21 +5,38 @@ import { DialogTitle, } from "@/components/ui/dialog"; import type { DialogProps } from "@/providers/DialogProvider/types"; +import type { DocumentKind } from "@/services/projects/resourceDescriptor"; import type { CreateResourceInput } from "@/services/projects/types"; +interface AddDocumentDialogProps { + kind?: DocumentKind; +} + +const COPY: Record = { + document: { + title: "Add a document", + description: "Write a document for this project.", + }, + code: { + title: "Add code", + description: "Add a code file to this project.", + }, +}; + export function AddDocumentDialog({ close, cancel, -}: DialogProps) { + kind = "document", +}: DialogProps) { return ( <> - Add a document + {COPY[kind].title} - Write a document for this project. + {COPY[kind].description} - + ); } diff --git a/src/routes/v2/pages/Tangent/components/AddResourceButton.test.tsx b/src/routes/v2/pages/Tangent/components/AddResourceButton.test.tsx index 1a96d6c41..5641f81e2 100644 --- a/src/routes/v2/pages/Tangent/components/AddResourceButton.test.tsx +++ b/src/routes/v2/pages/Tangent/components/AddResourceButton.test.tsx @@ -3,7 +3,10 @@ import userEvent from "@testing-library/user-event"; import { afterEach, describe, expect, it, vi } from "vitest"; import { remotePipelineResourceInput } from "@/services/projects/pipelineResource"; -import { localPipelineResourceInput } from "@/services/projects/resourceDescriptor"; +import { + documentResourceInput, + localPipelineResourceInput, +} from "@/services/projects/resourceDescriptor"; import type { CreateResourceInput, ProjectResourceSummary, @@ -142,4 +145,36 @@ describe("AddResourceButton", () => { expect(createResource).toHaveBeenCalledWith(PICKED_SAVED_PIPELINE); }); + + it.each([ + ["Add a document", "document"], + ["Add code", "code"], + ] as const)( + "opens %s as its own kind of the one form", + async (item, kind) => { + const picked = documentResourceInput(kind, "train.py", "print(1)"); + openDialog.mockResolvedValue(picked); + const user = userEvent.setup(); + render(); + + await user.click( + screen.getByRole("button", { name: "More resource types" }), + ); + const menuItem = await screen.findByRole("menuitem", { name: item }); + expect(menuItem).toHaveAttribute( + "data-tracking-id", + "projects.add_document_open", + ); + expect(menuItem).toHaveAttribute( + "data-tracking-metadata", + JSON.stringify({ kind }), + ); + await user.click(menuItem); + + expect(openDialog).toHaveBeenCalledWith( + expect.objectContaining({ routeKey: `add-${kind}`, props: { kind } }), + ); + expect(createResource).toHaveBeenCalledWith(picked); + }, + ); }); diff --git a/src/routes/v2/pages/Tangent/components/AddResourceButton.tsx b/src/routes/v2/pages/Tangent/components/AddResourceButton.tsx index 3850a8ce9..4910a8949 100644 --- a/src/routes/v2/pages/Tangent/components/AddResourceButton.tsx +++ b/src/routes/v2/pages/Tangent/components/AddResourceButton.tsx @@ -15,7 +15,11 @@ import { AddDocumentDialog } from "@/routes/v2/pages/Tangent/components/AddDocum import { AddPipelineDialog } from "@/routes/v2/pages/Tangent/components/AddPipelineDialog"; import { AddPipelineRunDialog } from "@/routes/v2/pages/Tangent/components/AddPipelineRunDialog"; import type { LocalPipelinePointer } from "@/services/localPipelines/types"; -import { localPipelinePointerOf } from "@/services/projects/resourceDescriptor"; +import { + type DocumentKind, + localPipelinePointerOf, +} from "@/services/projects/resourceDescriptor"; +import { resourceMeta } from "@/services/projects/resourceMeta"; import type { CreateResourceInput, ProjectResourceSummary, @@ -28,8 +32,6 @@ interface AddResourceButtonProps { resources: ProjectResourceSummary[]; } -type ResourceDialogComponent = DialogConfig["component"]; - function identityOf(metadata: Record | null | undefined) { const identity = metadata?.identity; return typeof identity === "string" ? identity : undefined; @@ -83,15 +85,17 @@ export function AddResourceButton({ const { mutate: createResource, isPending } = useCreateProjectResource(projectId); - async function openResourceDialog( - component: ResourceDialogComponent, + async function openResourceDialog( + component: DialogConfig["component"], routeKey: string, size: DialogConfig["size"] = "full", + props?: TProps, ) { - const result = await open({ + const result = await open({ component, routeKey, size, + props, }).catch(convertCancelErrorTo(undefined)); if (!result) return; @@ -114,8 +118,8 @@ export function AddResourceButton({ // The pipeline and run pickers are browsers and want the whole screen; a // document editor wants width, but lines that span a wide monitor read badly. - function handleAddDocument() { - void openResourceDialog(AddDocumentDialog, "add-document", "xl"); + function handleAddDocument(kind: DocumentKind) { + void openResourceDialog(AddDocumentDialog, `add-${kind}`, "xl", { kind }); } return ( @@ -158,12 +162,19 @@ export function AddResourceButton({ Add pipeline run handleAddDocument("document")} + {...tracking("projects.add_document_open", { kind: "document" })} > - + Add a document + handleAddDocument("code")} + {...tracking("projects.add_document_open", { kind: "code" })} + > + + Add code + diff --git a/src/routes/v2/pages/Tangent/components/ResourcesWindowContent.test.tsx b/src/routes/v2/pages/Tangent/components/ResourcesWindowContent.test.tsx index 5b8c4803f..3e1349ca8 100644 --- a/src/routes/v2/pages/Tangent/components/ResourcesWindowContent.test.tsx +++ b/src/routes/v2/pages/Tangent/components/ResourcesWindowContent.test.tsx @@ -200,6 +200,21 @@ describe("ResourcesWindowContent", () => { ); }); + it("lists a code file as code and opens it in a document tab, by the row that holds it", async () => { + given(resource("r-6", "train.py", { type: "code" })); + const user = userEvent.setup(); + + render(); + expect(screen.getByText("Code")).toBeInTheDocument(); + + await user.click(screen.getByTestId("open-resource-r-6")); + + expect(openWorkareaTarget).toHaveBeenCalledWith( + { type: "document", identity: "id/r-6" }, + "train.py", + ); + }); + /** It belongs to the project, so hiding it would make the list look wrong. */ it("lists a row it has no way to open, without offering to open it", () => { given(resource("r-3", "Mystery", { type: "something_else" })); diff --git a/src/routes/v2/pages/Tangent/components/ResourcesWindowContent.tsx b/src/routes/v2/pages/Tangent/components/ResourcesWindowContent.tsx index 3d0987356..28b7e3fcd 100644 --- a/src/routes/v2/pages/Tangent/components/ResourcesWindowContent.tsx +++ b/src/routes/v2/pages/Tangent/components/ResourcesWindowContent.tsx @@ -12,6 +12,7 @@ import { useTangentProject } from "@/routes/v2/pages/Tangent/context/TangentProj import { remoteResourceTarget } from "@/services/projects/pipelineResource"; import { UNTITLED } from "@/services/projects/placeholderNames"; import { + CODE, describeResource, DOCUMENT, INSTRUCTIONS, @@ -64,15 +65,15 @@ const OTHER_META = { }; /** - * A document carries its own body, so it records no identity — the row is the - * document, and the row's own id is what addresses it. + * A document or code file carries its own body, so it records no identity — + * the row is the document, and the row's own id is what addresses it. */ function targetOf( resource: ProjectResourceSummary, type: string, recorded: WorkareaTarget | undefined, ): WorkareaTarget | undefined { - if (type === DOCUMENT) { + if (type === DOCUMENT || type === CODE) { return { type: "document", identity: idIdentity(resource.id) }; } return recorded; diff --git a/src/services/projects/resourceDescriptor.test.ts b/src/services/projects/resourceDescriptor.test.ts index af4a77176..4435d3b0d 100644 --- a/src/services/projects/resourceDescriptor.test.ts +++ b/src/services/projects/resourceDescriptor.test.ts @@ -3,6 +3,7 @@ import { describe, expect, it } from "vitest"; import { describeResource, DescriptorTooLargeError, + documentKindOf, documentResourceInput, localPipelinePointerOf, localPipelineResourceInput, @@ -181,9 +182,25 @@ describe("localPipelineResourceInput", () => { }); }); +describe("documentKindOf", () => { + it("tells code from a document", () => { + expect(documentKindOf({ metadata: { type: "code" } })).toBe("code"); + expect(documentKindOf({ metadata: { type: "document" } })).toBe("document"); + }); + + it("treats any other row it is asked about as a document", () => { + expect(documentKindOf({ metadata: null })).toBe("document"); + expect(documentKindOf({ metadata: { type: "instructions" } })).toBe( + "document", + ); + }); +}); + describe("documentResourceInput", () => { it("files the body as the payload and says what the row is", () => { - expect(documentResourceInput("Model card", "Trained on Q3")).toEqual({ + expect( + documentResourceInput("document", "Model card", "Trained on Q3"), + ).toEqual({ entity: "document", name: "Model card", payload: { content: "Trained on Q3" }, @@ -193,10 +210,27 @@ describe("documentResourceInput", () => { /** Both pages filter on `type`, so a document has to answer that question. */ it("reads back as a document", () => { - const input = documentResourceInput("Model card", "Body"); + const input = documentResourceInput("document", "Model card", "Body"); expect(describeResource({ metadata: input.metadata ?? null })).toEqual({ type: "document", }); }); + + it("files code as a document that says it is code", () => { + expect(documentResourceInput("code", "train.py", "print(1)")).toEqual({ + entity: "document", + name: "train.py", + payload: { content: "print(1)" }, + metadata: { type: "code" }, + }); + }); + + it("reads code back as code", () => { + const input = documentResourceInput("code", "train.py", "print(1)"); + + expect(describeResource({ metadata: input.metadata ?? null })).toEqual({ + type: "code", + }); + }); }); diff --git a/src/services/projects/resourceDescriptor.ts b/src/services/projects/resourceDescriptor.ts index 00a7455de..2bda143ee 100644 --- a/src/services/projects/resourceDescriptor.ts +++ b/src/services/projects/resourceDescriptor.ts @@ -14,9 +14,12 @@ import type { CreateResourceInput, ProjectResourceSummary } from "./types"; export const LOCAL_PIPELINE = "local_pipeline"; export const PIPELINE_RUN = "pipeline_run"; export const DOCUMENT = "document"; +export const CODE = "code"; export const INSTRUCTIONS = "instructions"; export const AGENT_SESSION = "agent_session"; +export type DocumentKind = typeof DOCUMENT | typeof CODE; + const BROWSER = "browser"; const METADATA_LIMIT = 1024; @@ -69,6 +72,9 @@ export function describeResource( export const namesLocalPipeline = (resource: ResourceRow): boolean => describeResource(resource)?.type === LOCAL_PIPELINE; +export const documentKindOf = (resource: ResourceRow): DocumentKind => + describeResource(resource)?.type === CODE ? CODE : DOCUMENT; + /** * The id is tried first because a name can be recycled; the name is the * fallback because most pipelines in a browser have no registry row. @@ -145,16 +151,17 @@ export function localPipelineResourceInput( /** No identity to record: the row is the document. */ export function documentResourceInput( - title: string, + kind: DocumentKind, + name: string, content: string, ): CreateResourceInput { return { entity: "document", - name: title, + name, // The backend validates nothing inside a document payload; `content` is // this app's convention for the whole body. payload: { content }, - metadata: { type: DOCUMENT }, + metadata: { type: kind }, }; } diff --git a/src/services/projects/resourceMeta.ts b/src/services/projects/resourceMeta.ts index 9088db334..a763f9477 100644 --- a/src/services/projects/resourceMeta.ts +++ b/src/services/projects/resourceMeta.ts @@ -1,8 +1,14 @@ import type { IconName } from "@/components/ui/icon"; -import { DOCUMENT, LOCAL_PIPELINE, PIPELINE_RUN } from "./resourceDescriptor"; +import { + CODE, + DOCUMENT, + LOCAL_PIPELINE, + PIPELINE_RUN, +} from "./resourceDescriptor"; -export type ResourceConcept = "pipeline" | "run" | "document" | "session"; +export type ResourceConcept = + "pipeline" | "run" | "document" | "code" | "session"; interface ResourceMeta { icon: IconName; @@ -21,6 +27,7 @@ const RESOURCE_META: Record = { pipeline: { icon: "GitBranch", label: "Pipeline" }, run: { icon: "Play", label: "Pipeline run" }, document: { icon: "FileText", label: "Document" }, + code: { icon: "FileCode", label: "Code" }, session: { icon: "MessageSquare", label: "Session" }, }; @@ -40,6 +47,7 @@ const DESCRIPTOR_CONCEPTS: Record = { [LOCAL_PIPELINE]: "pipeline", [PIPELINE_RUN]: "run", [DOCUMENT]: "document", + [CODE]: "code", }; export const conceptForDescriptorType = (