@marimo-team/frontend 0.23.16-dev76 → 0.23.16-dev8

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.
Files changed (165) hide show
  1. package/dist/assets/{CellStatus-BalYv0pK.js → CellStatus-BwCqFeIT.js} +1 -1
  2. package/dist/assets/{ConnectedDataExplorerComponent-DI7mV-Gw.js → ConnectedDataExplorerComponent-CMaZapRd.js} +1 -1
  3. package/dist/assets/{ErrorBoundary-C7_5jjWi.js → ErrorBoundary-DzTD8LVq.js} +1 -1
  4. package/dist/assets/{ImperativeModal-BIavOiUv.js → ImperativeModal-fY-ZfT0b.js} +1 -1
  5. package/dist/assets/JsonOutput-B0aYK7up.js +53 -0
  6. package/dist/assets/{LazyAnyLanguageCodeMirror-sVm_hz8A.js → LazyAnyLanguageCodeMirror-B2JvSXP-.js} +2 -2
  7. package/dist/assets/{MarimoErrorOutput-DO2k2fA5.js → MarimoErrorOutput-2CXbNUBb.js} +1 -1
  8. package/dist/assets/{RSPContexts-CgC81f0I.js → RSPContexts-WiCgGm__.js} +1 -1
  9. package/dist/assets/{RenderHTML-BCvU9dYz.js → RenderHTML-uBc4LkCP.js} +1 -1
  10. package/dist/assets/{RunButton-BKRupqr7.js → RunButton-DLoWn_Jd.js} +1 -1
  11. package/dist/assets/{add-cell-with-ai-CEZtaZCK.js → add-cell-with-ai-C1L7x0xr.js} +1 -1
  12. package/dist/assets/{add-connection-dialog-CEp3SJR2.js → add-connection-dialog-BgQ7HuWu.js} +1 -1
  13. package/dist/assets/{agent-panel-By4A-Cqr.js → agent-panel-D3_CUvz_.js} +1 -1
  14. package/dist/assets/ai-model-dropdown-C7iiWBqP.js +5 -0
  15. package/dist/assets/{alert-dialog-BXegNC0I.js → alert-dialog-Bb8Lz8ko.js} +1 -1
  16. package/dist/assets/{any-language-editor-DCpZRrpx.js → any-language-editor--tRHs8C_.js} +1 -1
  17. package/dist/assets/{app-config-button-NrM9ywXA.js → app-config-button-DhmeKMjL.js} +1 -1
  18. package/dist/assets/{button-eIihcbUw.js → button-DDGiD1NW.js} +1 -1
  19. package/dist/assets/{cache-panel-wllSTlbM.js → cache-panel-DcQUeUpg.js} +1 -1
  20. package/dist/assets/{capabilities-B09j1Eor.js → capabilities-DoEg0XwL.js} +1 -1
  21. package/dist/assets/{cell-editor-DdofA9oQ.js → cell-editor-DD0zaU_N.js} +10 -10
  22. package/dist/assets/{cell-link-DqBkyAFe.js → cell-link-C0hhYyNu.js} +1 -1
  23. package/dist/assets/{cells-Cf7bHHq3.js → cells-aqfX084m.js} +6 -6
  24. package/dist/assets/{chat-display-BG08ltQ3.js → chat-display-DomMe89U.js} +1 -1
  25. package/dist/assets/{chat-panel-P2CzCtXJ.js → chat-panel-DzLbxcWT.js} +1 -1
  26. package/dist/assets/{chat-ui-fAJZAoTD.js → chat-ui-BpWZ6JeY.js} +1 -1
  27. package/dist/assets/{column-preview-BgZLUEOj.js → column-preview-D5Btj8aG.js} +1 -1
  28. package/dist/assets/{command-CAJLPEBq.js → command-Dqxie4gq.js} +1 -1
  29. package/dist/assets/{command-palette-DrI24FoL.js → command-palette-CNp7T2c5.js} +1 -1
  30. package/dist/assets/{common-CD6Mj8n6.js → common-BrPgvCtD.js} +1 -1
  31. package/dist/assets/{components-BH6gDVv6.js → components-1i9OlkxM.js} +1 -1
  32. package/dist/assets/{config-COUOIPEq.js → config-BJVx3eBW.js} +1 -1
  33. package/dist/assets/{config-BbOTTCYY.js → config-CR3KxW53.js} +1 -1
  34. package/dist/assets/{copy-BtTOqPTS.js → copy-Bxb1_Gtw.js} +1 -1
  35. package/dist/assets/{copy-icon-C2pG0ZNK.js → copy-icon-neDQUo4Q.js} +1 -1
  36. package/dist/assets/{createReducer-C7kZ8zEe.js → createReducer-DGx5Af-C.js} +1 -1
  37. package/dist/assets/{datasource-ySAiC9gr.js → datasource-wn1PTlXA.js} +1 -1
  38. package/dist/assets/{dates-D-kIKDYA.js → dates-C2LWzsCZ.js} +1 -1
  39. package/dist/assets/{dependency-graph-panel-BF23EwcD.js → dependency-graph-panel-B8Q8RQNh.js} +1 -1
  40. package/dist/assets/{dialog-D1DEsRZ2.js → dialog-BCVepWLJ.js} +1 -1
  41. package/dist/assets/{dist-ByVeMWkr.js → dist-BLN8PNJV.js} +1 -1
  42. package/dist/assets/dist-CiVShZKM.js +2 -0
  43. package/dist/assets/{documentation-panel-e9Fi9R7e.js → documentation-panel-hFyikugH.js} +1 -1
  44. package/dist/assets/{download-BomYt3nl.js → download-CkMUlfM3.js} +1 -1
  45. package/dist/assets/{dropdown-menu-BFZLnWeO.js → dropdown-menu-dHmZR8bI.js} +1 -1
  46. package/dist/assets/{edit-page-C37w54dQ.js → edit-page-jUsez4HZ.js} +5 -5
  47. package/dist/assets/{error-banner-B6eIlxzg.js → error-banner-BSxEOIn2.js} +1 -1
  48. package/dist/assets/{error-entries-Birw-8VX.js → error-entries-NXVkubuN.js} +1 -1
  49. package/dist/assets/{error-panel-DdsUL6b7.js → error-panel-DAseRhAS.js} +1 -1
  50. package/dist/assets/{es-1D83cgiu.js → es-gYgfF8y5.js} +1 -1
  51. package/dist/assets/{esm-BL7jQmzn.js → esm-B30N1GS0.js} +1 -1
  52. package/dist/assets/{field-BeZbVzXA.js → field-BsxD35VE.js} +1 -1
  53. package/dist/assets/file-explorer-panel-D7VHUIPp.js +26 -0
  54. package/dist/assets/{file-icons-Cb54mIL8.js → file-icons-Chripuk-.js} +1 -1
  55. package/dist/assets/{file-name-input-BWFcK1ey.js → file-name-input-B1NJd3KY.js} +1 -1
  56. package/dist/assets/{floating-outline-wz-WSRgP.js → floating-outline-BOqWJRw_.js} +1 -1
  57. package/dist/assets/{focus-DiEyfiAG.js → focus-tOmGbv38.js} +1 -1
  58. package/dist/assets/{form-CgWNb44_.js → form-CIXAzTfC.js} +1 -1
  59. package/dist/assets/{formats-ClEFGPy-.js → formats-DzmLpc9A.js} +1 -1
  60. package/dist/assets/{formatting-B_41gSF5.js → formatting-CDf6w1Kt.js} +1 -1
  61. package/dist/assets/{fullscreen-dUjPYOmm.js → fullscreen-CTDqNQr6.js} +1 -1
  62. package/dist/assets/{gallery-page-CWQQAYpa.js → gallery-page-CJ3ms1Hn.js} +1 -1
  63. package/dist/assets/{glide-data-editor-lMARFWHS.js → glide-data-editor-j2igs9Y_.js} +1 -1
  64. package/dist/assets/{home-page-DJ4jJsZj.js → home-page-DO_MqQAB.js} +1 -1
  65. package/dist/assets/{html-to-image-CwmX28T9.js → html-to-image-B-nAUvDC.js} +1 -1
  66. package/dist/assets/index-wLUfvfaV.js +38 -0
  67. package/dist/assets/{index-BjlEpvLC.css → index-xs-zSSrX.css} +1 -1
  68. package/dist/assets/{input-CqrUjynb.js → input-JxFwu-3G.js} +1 -1
  69. package/dist/assets/{kiosk-mode-ABm3oYgq.js → kiosk-mode-QsYGYiC8.js} +1 -1
  70. package/dist/assets/{label-BElwWzf0.js → label-CqjrpwRC.js} +1 -1
  71. package/dist/assets/{layout-BOwE7yok.js → layout-1Z2AhO6k.js} +3 -3
  72. package/dist/assets/{links-CToBjIIj.js → links-CZr5bjvo.js} +1 -1
  73. package/dist/assets/{logs-panel-iFV4ZJ9b.js → logs-panel-B6AfYQb6.js} +1 -1
  74. package/dist/assets/{markdown-renderer-QvXG0CRk.js → markdown-renderer-Cs1sISb9.js} +1 -1
  75. package/dist/assets/{menu-items-DbSLBfXR.js → menu-items-CCi6aOPm.js} +1 -1
  76. package/dist/assets/{mermaid-Bh0flEIb.js → mermaid-CYN0wOyG.js} +1 -1
  77. package/dist/assets/{name-cell-input-C6cukQ9k.js → name-cell-input-2sM8a_OS.js} +1 -1
  78. package/dist/assets/{numbers-CeeaFKqu.js → numbers-rmawRqQP.js} +1 -1
  79. package/dist/assets/{outline-panel-BPlg9_CE.js → outline-panel-BXWCkGu_.js} +1 -1
  80. package/dist/assets/{packages-panel-BzQF60rG.js → packages-panel-CJfHGfax.js} +1 -1
  81. package/dist/assets/{pair-with-agent-modal-CKOYwOYw.js → pair-with-agent-modal-C9nKHvCp.js} +1 -1
  82. package/dist/assets/{panels-CW-PUlv2.js → panels-Ch_sUzTx.js} +1 -1
  83. package/dist/assets/{popover-DrHQ3KEt.js → popover-Bcz1Zikb.js} +1 -1
  84. package/dist/assets/process-output-BFSCpqNt.js +1 -0
  85. package/dist/assets/{radio-group-CLBjGkt5.js → radio-group-PT0_zVHo.js} +1 -1
  86. package/dist/assets/{readonly-code-display-D63oRtBP.js → readonly-code-display-CFbCSGms.js} +1 -1
  87. package/dist/assets/{readonly-python-code-CW_G5e4E.js → readonly-python-code-DB23yd7T.js} +1 -1
  88. package/dist/assets/{renderShortcut-BZLr8D7H.js → renderShortcut-Bl8_FVn8.js} +1 -1
  89. package/dist/assets/{reveal-component-DqMRxmUr.js → reveal-component-fwTDxf5N.js} +57 -57
  90. package/dist/assets/{run-page-s6Mf8p78.js → run-page-D9-9chbw.js} +1 -1
  91. package/dist/assets/{runs-D51Xwrsk.js → runs-DBTXqJHS.js} +1 -1
  92. package/dist/assets/{scratchpad-panel-CXsxdMB-.js → scratchpad-panel-BLC2oQ_3.js} +1 -1
  93. package/dist/assets/{scroll-to-bottom-button-Cgr10kaJ.js → scroll-to-bottom-button-C8yfT4wq.js} +1 -1
  94. package/dist/assets/{secrets-panel-Cc5KDBk6.js → secrets-panel-6J6wcuVR.js} +1 -1
  95. package/dist/assets/{select-B5TFNjmU.js → select-BP75_5jJ.js} +1 -1
  96. package/dist/assets/{session-CEFnN9Cq.js → session-BmukRjSi.js} +1 -1
  97. package/dist/assets/{session-panel-CmHetqAb.js → session-panel-Dz-DQDeV.js} +1 -1
  98. package/dist/assets/{share-CYpcX4N4.js → share-852X_cqy.js} +1 -1
  99. package/dist/assets/{skeleton-B_4CZVEm.js → skeleton-xuR254Kw.js} +1 -1
  100. package/dist/assets/{snippets-panel-CXpGflMO.js → snippets-panel-DwAjNUqR.js} +1 -1
  101. package/dist/assets/{spec-CzqcmYQk.js → spec-C7opZZRp.js} +1 -1
  102. package/dist/assets/{state-BXbiRXZk.js → state-BpkJVsmT.js} +1 -1
  103. package/dist/assets/{state-BvdK8eiw.js → state-D10U2MwA.js} +1 -1
  104. package/dist/assets/{state-WBgc7MiA.js → state-DDag0B_B.js} +1 -1
  105. package/dist/assets/{state-DBzEQtWz.js → state-Tc7pRohh.js} +1 -1
  106. package/dist/assets/{strings-Djnbwmfl.js → strings--BknHX9C.js} +1 -1
  107. package/dist/assets/{swiper-component-DInvCxhI.js → swiper-component-Che9MNs4.js} +1 -1
  108. package/dist/assets/{switch-CvweaCXq.js → switch-DLGijIXN.js} +1 -1
  109. package/dist/assets/{terminal-D3IQBBYo.js → terminal-DaLxAnKx.js} +1 -1
  110. package/dist/assets/{textarea-CpFfEUet.js → textarea-aocYW9hl.js} +1 -1
  111. package/dist/assets/{tooltip-CFX4fYHU.js → tooltip-4bZlU7jl.js} +1 -1
  112. package/dist/assets/{tooltip-HLb6RxVp.js → tooltip-B7U4xkIN.js} +1 -1
  113. package/dist/assets/{tracing-CKn3gWHw.js → tracing-BmBBYynL.js} +1 -1
  114. package/dist/assets/{tracing-panel-BU-o_LCd.js → tracing-panel-AReiFUol.js} +2 -2
  115. package/dist/assets/{tree-actions-CsC2XmGo.js → tree-actions-Bz3fJ7p-.js} +1 -1
  116. package/dist/assets/{useBoolean-D89u30n-.js → useBoolean-CA7K8rap.js} +1 -1
  117. package/dist/assets/{useCellActionButton-CRfIEVB5.js → useCellActionButton-BG7n5b0H.js} +1 -1
  118. package/dist/assets/{useDeleteCell-EPH4kcG7.js → useDeleteCell-DQP-NBq2.js} +1 -1
  119. package/dist/assets/{useDependencyPanelTab-CZnK7N2L.js → useDependencyPanelTab-BrkWlCTP.js} +1 -1
  120. package/dist/assets/useEventListener-C0195JsF.js +1 -0
  121. package/dist/assets/{useHotkey-DIuRH8Wu.js → useHotkey-DLZvrRZT.js} +1 -1
  122. package/dist/assets/{useIframeCapabilities-Ccb4fvKc.js → useIframeCapabilities-j6fR_dBA.js} +1 -1
  123. package/dist/assets/{useInstallPackage-BRbUAUFn.js → useInstallPackage-WiYBwhUO.js} +1 -1
  124. package/dist/assets/{useInterval-CC0vKpWm.js → useInterval-CWBTUy5W.js} +1 -1
  125. package/dist/assets/{useNotebookActions-DJKDd0gM.js → useNotebookActions-COdGI2fR.js} +1 -1
  126. package/dist/assets/{useRunCells-CSSF_mJ0.js → useRunCells-BtSwerrU.js} +1 -1
  127. package/dist/assets/{useSplitCell-9Qz93yPZ.js → useSplitCell-jH70A2Sz.js} +1 -1
  128. package/dist/assets/{useTheme-KXqAyDPC.js → useTheme-Bxv9B31g.js} +1 -1
  129. package/dist/assets/{utils-Z80SV4_d.js → utils-BWBBpCkT.js} +1 -1
  130. package/dist/assets/{vega-component-Cie3ag_T.js → vega-component-DSvWLXIy.js} +1 -1
  131. package/dist/assets/{write-secret-modal-DEEn7sZV.js → write-secret-modal-3-v-dHVj.js} +1 -1
  132. package/dist/index.html +67 -67
  133. package/package.json +1 -1
  134. package/src/components/data-table/column-formatting/feature.ts +2 -1
  135. package/src/components/editor/file-tree/__tests__/file-render-mode.test.ts +27 -0
  136. package/src/components/editor/file-tree/__tests__/file-viewer.test.tsx +150 -0
  137. package/src/components/editor/file-tree/file-preview-metadata.tsx +51 -0
  138. package/src/components/editor/file-tree/file-render-mode.ts +32 -0
  139. package/src/components/editor/file-tree/file-viewer.tsx +76 -50
  140. package/src/components/editor/output/CalloutOutput.tsx +17 -3
  141. package/src/components/slides/__tests__/slide-scroll-hint.test.tsx +198 -0
  142. package/src/components/slides/reveal-component.tsx +3 -2
  143. package/src/components/slides/slide-scroll-hint.tsx +161 -0
  144. package/src/components/ui/input.tsx +1 -1
  145. package/src/core/codemirror/keymaps/__tests__/keymaps.test.ts +31 -2
  146. package/src/core/codemirror/keymaps/keymaps.ts +12 -0
  147. package/src/core/hotkeys/hotkeys.ts +0 -2
  148. package/src/core/i18n/__tests__/locale-provider.test.tsx +54 -118
  149. package/src/core/i18n/__tests__/locale.test.ts +113 -0
  150. package/src/core/i18n/locale-provider.tsx +1 -19
  151. package/src/core/i18n/locale.ts +71 -0
  152. package/src/core/network/types.ts +3 -1
  153. package/src/css/admonition.css +52 -71
  154. package/src/plugins/impl/FormPlugin.tsx +1 -1
  155. package/src/plugins/impl/__tests__/FormPlugin.test.tsx +162 -0
  156. package/src/plugins/layout/CalloutPlugin.tsx +8 -1
  157. package/src/stories/callout.stories.tsx +12 -0
  158. package/dist/assets/JsonOutput-DnGSkLeP.js +0 -53
  159. package/dist/assets/ai-model-dropdown-DXgLphFa.js +0 -5
  160. package/dist/assets/dist-BCMBu0rO.js +0 -2
  161. package/dist/assets/file-explorer-panel-DbdoJbbF.js +0 -26
  162. package/dist/assets/index-D73cl2ti.js +0 -38
  163. package/dist/assets/process-output-BEhIGfkO.js +0 -1
  164. package/dist/assets/useEventListener-B_w3A283.js +0 -1
  165. package/src/components/editor/output/CalloutOutput.styles.ts +0 -22
@@ -25,11 +25,11 @@ import { copyToClipboard } from "@/utils/copy";
25
25
  import type { Base64String } from "@/utils/json/base64";
26
26
  import { downloadFile } from "./download";
27
27
  import { FilePreviewHeader } from "./file-header";
28
- import {
29
- FileContentRenderer,
30
- isMediaMime,
31
- MIME_TO_LANGUAGE,
32
- } from "./renderers";
28
+ import { FilePreviewMetadata } from "./file-preview-metadata";
29
+ import { getFileRenderMode } from "./file-render-mode";
30
+ import { FileContentRenderer } from "./renderers";
31
+
32
+ export const MAX_FILE_PREVIEW_BYTES = 10 * 1024 * 1024;
33
33
 
34
34
  interface Props {
35
35
  file: FileInfo;
@@ -50,9 +50,15 @@ export const FileViewer: React.FC<Props> = ({ file, onOpenNotebook }) => {
50
50
 
51
51
  const { data, isPending, error, setData, refetch } =
52
52
  useAsyncData(async () => {
53
- const details = await sendFileDetails({ path: file.path });
54
- const contents = details.contents || "";
55
- setInternalValue(unsavedContentsForFile.get(file.path) || contents);
53
+ const details = await sendFileDetails({
54
+ path: file.path,
55
+ maxBytes: MAX_FILE_PREVIEW_BYTES,
56
+ });
57
+ const mode = getFileRenderMode(details.mimeType, details.isBase64);
58
+ if (!details.isTooLarge && mode === "text") {
59
+ const contents = details.contents || "";
60
+ setInternalValue(unsavedContentsForFile.get(file.path) ?? contents);
61
+ }
56
62
  return details;
57
63
  }, [file.path]);
58
64
 
@@ -72,13 +78,19 @@ export const FileViewer: React.FC<Props> = ({ file, onOpenNotebook }) => {
72
78
  );
73
79
  };
74
80
 
75
- // On file change or unmount, save the unsaved contents
81
+ // On file change or unmount, save the unsaved contents. Only editable text
82
+ // previews participate in editor state; oversized and non-text responses
83
+ // carry no draft to preserve.
76
84
  // We use a ref for internalValue so we don't call this effect on each keystroke
77
85
  const internalValueRef = useRef<string>(internalValue);
78
86
  internalValueRef.current = internalValue;
79
87
  useEffect(() => {
80
88
  return () => {
81
- if (!data?.contents) {
89
+ if (
90
+ data?.contents == null ||
91
+ data.isTooLarge ||
92
+ getFileRenderMode(data.mimeType, data.isBase64) !== "text"
93
+ ) {
82
94
  return;
83
95
  }
84
96
  const draft = internalValueRef.current;
@@ -88,7 +100,13 @@ export const FileViewer: React.FC<Props> = ({ file, onOpenNotebook }) => {
88
100
  unsavedContentsForFile.set(file.path, draft);
89
101
  }
90
102
  };
91
- }, [file.path, data?.contents]);
103
+ }, [
104
+ file.path,
105
+ data?.contents,
106
+ data?.isTooLarge,
107
+ data?.mimeType,
108
+ data?.isBase64,
109
+ ]);
92
110
 
93
111
  if (error) {
94
112
  return <ErrorBanner error={error} />;
@@ -99,7 +117,10 @@ export const FileViewer: React.FC<Props> = ({ file, onOpenNotebook }) => {
99
117
  }
100
118
 
101
119
  const mimeType = data.mimeType || "text/plain";
102
- const isEditable = mimeType in MIME_TO_LANGUAGE;
120
+ const renderMode = getFileRenderMode(data.mimeType, data.isBase64);
121
+ const isText = renderMode === "text";
122
+ const isCsv = renderMode === "csv";
123
+ const isMedia = renderMode === "media";
103
124
  const isActiveNotebook =
104
125
  currentNotebookFilename &&
105
126
  data.file.isMarimoFile &&
@@ -108,22 +129,27 @@ export const FileViewer: React.FC<Props> = ({ file, onOpenNotebook }) => {
108
129
  // but this is an okay heuristic for now.
109
130
  file.path.endsWith(`/${currentNotebookFilename}`));
110
131
 
111
- if (!data.contents && !isEditable) {
112
- // Show details instead of contents
113
- return (
114
- <div className="grid grid-cols-2 gap-2 p-6">
115
- <div className="font-bold text-muted-foreground">Name</div>
116
- <div>{data.file.name}</div>
117
- <div className="font-bold text-muted-foreground">Type</div>
118
- <div>{mimeType}</div>
119
- </div>
120
- );
121
- }
122
-
123
132
  const handleDownload = async () => {
124
133
  await downloadFile(file.path, data.file.name);
125
134
  };
126
135
 
136
+ const saveKeymapExtension = keymap.of([
137
+ {
138
+ key: hotkeys.getHotkey("global.save").key,
139
+ stopPropagation: true,
140
+ run: () => {
141
+ if (internalValue !== data.contents) {
142
+ void handleSaveFile();
143
+ return true;
144
+ }
145
+ return false;
146
+ },
147
+ },
148
+ ]);
149
+
150
+ // Defined before the metadata-only returns so refresh, download, and
151
+ // open-notebook stay available while edit-only copy/save are withheld from
152
+ // non-text previews.
127
153
  const header = (
128
154
  <FilePreviewHeader
129
155
  filename={data.file.name}
@@ -142,7 +168,7 @@ export const FileViewer: React.FC<Props> = ({ file, onOpenNotebook }) => {
142
168
  </Button>
143
169
  </Tooltip>
144
170
  )}
145
- {!isMediaMime(mimeType) && (
171
+ {isText && (
146
172
  <>
147
173
  <Tooltip content="Copy contents to clipboard">
148
174
  <Button
@@ -172,8 +198,23 @@ export const FileViewer: React.FC<Props> = ({ file, onOpenNotebook }) => {
172
198
  />
173
199
  );
174
200
 
175
- const isMedia = isMediaMime(mimeType);
176
- const isText = !isMedia && mimeType !== "text/csv";
201
+ if (data.isTooLarge || renderMode === "unsupported") {
202
+ return (
203
+ <>
204
+ {header}
205
+ <FilePreviewMetadata
206
+ file={data.file}
207
+ mimeType={mimeType}
208
+ message={
209
+ data.isTooLarge
210
+ ? "File is too large to preview."
211
+ : "This file type cannot be previewed."
212
+ }
213
+ onDownload={disableFileDownloads ? undefined : handleDownload}
214
+ />
215
+ </>
216
+ );
217
+ }
177
218
 
178
219
  const warningBanner = isText && isActiveNotebook && (
179
220
  <Alert variant="warning" className="rounded-none">
@@ -192,35 +233,20 @@ export const FileViewer: React.FC<Props> = ({ file, onOpenNotebook }) => {
192
233
  <FileContentRenderer
193
234
  mimeType={mimeType}
194
235
  contents={
195
- isMedia
196
- ? undefined
197
- : isText
198
- ? internalValue
199
- : (data.contents ?? undefined)
236
+ isText
237
+ ? internalValue
238
+ : isCsv
239
+ ? (data.contents ?? undefined)
240
+ : undefined
200
241
  }
201
242
  mediaSource={
202
- isMedia
243
+ isMedia && data.contents
203
244
  ? { base64: data.contents as Base64String, mime: mimeType }
204
245
  : undefined
205
246
  }
206
247
  readOnly={!isText}
207
- onChange={setInternalValue}
208
- extensions={[
209
- // Command S for save
210
- keymap.of([
211
- {
212
- key: hotkeys.getHotkey("global.save").key,
213
- stopPropagation: true,
214
- run: () => {
215
- if (internalValue !== data.contents) {
216
- handleSaveFile();
217
- return true;
218
- }
219
- return false;
220
- },
221
- },
222
- ]),
223
- ]}
248
+ onChange={isText ? setInternalValue : undefined}
249
+ extensions={isText ? [saveKeymapExtension] : []}
224
250
  />
225
251
  </>
226
252
  );
@@ -1,17 +1,31 @@
1
1
  /* Copyright 2026 Marimo. All rights reserved. */
2
2
  import { memo } from "react";
3
3
  import type { Intent } from "@/plugins/impl/common/intent";
4
- import { calloutStyles } from "./CalloutOutput.styles";
4
+ import { cn } from "@/utils/cn";
5
5
  import { HtmlOutput } from "./HtmlOutput";
6
6
 
7
7
  interface Props {
8
8
  html: string;
9
9
  kind: Intent;
10
+ title?: string;
10
11
  }
11
12
 
12
- export const CalloutOutput: React.FC<Props> = memo(({ html, kind }) => {
13
+ // Callouts share the flat admonition style of markdown admonitions
14
+ // (css/admonition.css); each intent maps onto an admonition kind.
15
+ const KIND_CLASS: Record<Intent, string> = {
16
+ neutral: "neutral",
17
+ info: "info",
18
+ warn: "warning",
19
+ success: "success",
20
+ danger: "danger",
21
+ // 'alert' is deprecated; render as danger
22
+ alert: "danger",
23
+ };
24
+
25
+ export const CalloutOutput: React.FC<Props> = memo(({ html, kind, title }) => {
13
26
  return (
14
- <div className={calloutStyles({ kind })}>
27
+ <div className={cn("admonition", KIND_CLASS[kind])}>
28
+ {title && <span className="admonition-title">{title}</span>}
15
29
  <HtmlOutput html={html} alwaysSanitizeHtml={true} />
16
30
  </div>
17
31
  );
@@ -0,0 +1,198 @@
1
+ /* Copyright 2026 Marimo. All rights reserved. */
2
+
3
+ import { act, render, screen } from "@testing-library/react";
4
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
5
+ import {
6
+ shouldShowScrollHint,
7
+ SlideScrollContainer,
8
+ } from "../slide-scroll-hint";
9
+
10
+ describe("shouldShowScrollHint", () => {
11
+ it("shows when content overflows and scroll is at the top", () => {
12
+ expect(
13
+ shouldShowScrollHint({
14
+ scrollHeight: 800,
15
+ clientHeight: 400,
16
+ scrollTop: 0,
17
+ }),
18
+ ).toBe(true);
19
+ });
20
+
21
+ it("hides when content fits", () => {
22
+ expect(
23
+ shouldShowScrollHint({
24
+ scrollHeight: 300,
25
+ clientHeight: 400,
26
+ scrollTop: 0,
27
+ }),
28
+ ).toBe(false);
29
+ });
30
+
31
+ it("hides after the user scrolls down", () => {
32
+ expect(
33
+ shouldShowScrollHint({
34
+ scrollHeight: 800,
35
+ clientHeight: 400,
36
+ scrollTop: 40,
37
+ }),
38
+ ).toBe(false);
39
+ });
40
+
41
+ it("ignores sub-pixel overflow", () => {
42
+ expect(
43
+ shouldShowScrollHint({
44
+ scrollHeight: 401,
45
+ clientHeight: 400,
46
+ scrollTop: 0,
47
+ }),
48
+ ).toBe(false);
49
+ });
50
+
51
+ it("still shows within the top threshold", () => {
52
+ expect(
53
+ shouldShowScrollHint({
54
+ scrollHeight: 800,
55
+ clientHeight: 400,
56
+ scrollTop: 8,
57
+ }),
58
+ ).toBe(true);
59
+ });
60
+ });
61
+
62
+ describe("SlideScrollContainer", () => {
63
+ const resizeObservers: Array<{
64
+ callback: ResizeObserverCallback;
65
+ observe: ReturnType<typeof vi.fn>;
66
+ disconnect: ReturnType<typeof vi.fn>;
67
+ }> = [];
68
+
69
+ beforeEach(() => {
70
+ resizeObservers.length = 0;
71
+ vi.stubGlobal("requestAnimationFrame", (cb: FrameRequestCallback) => {
72
+ cb(0);
73
+ return 0;
74
+ });
75
+ vi.stubGlobal("cancelAnimationFrame", vi.fn());
76
+ vi.stubGlobal(
77
+ "ResizeObserver",
78
+ class MockResizeObserver {
79
+ callback: ResizeObserverCallback;
80
+ observe = vi.fn();
81
+ unobserve = vi.fn();
82
+ disconnect = vi.fn();
83
+
84
+ constructor(callback: ResizeObserverCallback) {
85
+ this.callback = callback;
86
+ resizeObservers.push(this);
87
+ }
88
+ },
89
+ );
90
+ vi.stubGlobal(
91
+ "MutationObserver",
92
+ class MockMutationObserver {
93
+ observe = vi.fn();
94
+ disconnect = vi.fn();
95
+ takeRecords = vi.fn(() => []);
96
+ },
97
+ );
98
+ });
99
+
100
+ afterEach(() => {
101
+ vi.unstubAllGlobals();
102
+ });
103
+
104
+ function stubOverflow(
105
+ el: HTMLElement,
106
+ metrics: {
107
+ scrollHeight: number;
108
+ clientHeight: number;
109
+ scrollTop?: number;
110
+ },
111
+ ) {
112
+ Object.defineProperty(el, "scrollHeight", {
113
+ configurable: true,
114
+ get: () => metrics.scrollHeight,
115
+ });
116
+ Object.defineProperty(el, "clientHeight", {
117
+ configurable: true,
118
+ get: () => metrics.clientHeight,
119
+ });
120
+ Object.defineProperty(el, "scrollTop", {
121
+ configurable: true,
122
+ get: () => metrics.scrollTop ?? 0,
123
+ set: (value: number) => {
124
+ metrics.scrollTop = value;
125
+ },
126
+ });
127
+ }
128
+
129
+ it("renders a scroll hint when content overflows", () => {
130
+ render(
131
+ <SlideScrollContainer>
132
+ <div>tall content</div>
133
+ </SlideScrollContainer>,
134
+ );
135
+
136
+ const container = screen.getByTestId("slide-scroll-container");
137
+ stubOverflow(container, { scrollHeight: 900, clientHeight: 400 });
138
+
139
+ act(() => {
140
+ for (const observer of resizeObservers) {
141
+ observer.callback([], observer as unknown as ResizeObserver);
142
+ }
143
+ });
144
+
145
+ expect(screen.getByTestId("slide-scroll-hint")).toBeTruthy();
146
+ expect(screen.getByText("Scroll for more")).toBeTruthy();
147
+ });
148
+
149
+ it("hides the hint after scrolling", () => {
150
+ const metrics = {
151
+ scrollHeight: 900,
152
+ clientHeight: 400,
153
+ scrollTop: 0,
154
+ };
155
+
156
+ render(
157
+ <SlideScrollContainer>
158
+ <div>tall content</div>
159
+ </SlideScrollContainer>,
160
+ );
161
+
162
+ const container = screen.getByTestId("slide-scroll-container");
163
+ stubOverflow(container, metrics);
164
+
165
+ act(() => {
166
+ for (const observer of resizeObservers) {
167
+ observer.callback([], observer as unknown as ResizeObserver);
168
+ }
169
+ });
170
+ expect(screen.getByTestId("slide-scroll-hint")).toBeTruthy();
171
+
172
+ act(() => {
173
+ metrics.scrollTop = 50;
174
+ container.dispatchEvent(new Event("scroll"));
175
+ });
176
+
177
+ expect(screen.queryByTestId("slide-scroll-hint")).toBeNull();
178
+ });
179
+
180
+ it("does not show a hint when content fits", () => {
181
+ render(
182
+ <SlideScrollContainer>
183
+ <div>short content</div>
184
+ </SlideScrollContainer>,
185
+ );
186
+
187
+ const container = screen.getByTestId("slide-scroll-container");
188
+ stubOverflow(container, { scrollHeight: 200, clientHeight: 400 });
189
+
190
+ act(() => {
191
+ for (const observer of resizeObservers) {
192
+ observer.callback([], observer as unknown as ResizeObserver);
193
+ }
194
+ });
195
+
196
+ expect(screen.queryByTestId("slide-scroll-hint")).toBeNull();
197
+ });
198
+ });
@@ -14,6 +14,7 @@ import { CodeIcon, ExpandIcon, EyeOffIcon } from "lucide-react";
14
14
  import { Deck, Fragment, Slide, Stack } from "@revealjs/react";
15
15
  import { Panel, PanelGroup, PanelResizeHandle } from "react-resizable-panels";
16
16
  import { Slide as CellOutputSlide } from "@/components/slides/slide";
17
+ import { SlideScrollContainer } from "@/components/slides/slide-scroll-hint";
17
18
  import { Button } from "@/components/ui/button";
18
19
  import { useFullScreenElement } from "@/components/ui/fullscreen";
19
20
  import { Tooltip } from "@/components/ui/tooltip";
@@ -327,7 +328,7 @@ const SubslideView = ({
327
328
 
328
329
  return (
329
330
  <Slide>
330
- <div className="h-full w-full overflow-auto flex">
331
+ <SlideScrollContainer>
331
332
  <div
332
333
  className={
333
334
  anyCodeShown
@@ -367,7 +368,7 @@ const SubslideView = ({
367
368
  return <ReactFragment key={i}>{rendered}</ReactFragment>;
368
369
  })}
369
370
  </div>
370
- </div>
371
+ </SlideScrollContainer>
371
372
  {/* Outside any `.fragment`: shown only before any fragment is revealed. */}
372
373
  {slideLevel && <NotesAside text={slideLevel} />}
373
374
  </Slide>
@@ -0,0 +1,161 @@
1
+ /* Copyright 2026 Marimo. All rights reserved. */
2
+
3
+ import { ChevronDownIcon } from "lucide-react";
4
+ import {
5
+ type ReactNode,
6
+ type RefObject,
7
+ useEffect,
8
+ useRef,
9
+ useState,
10
+ } from "react";
11
+ import { cn } from "@/utils/cn";
12
+
13
+ /** Pixels of scroll before the hint is considered dismissed. */
14
+ const TOP_THRESHOLD_PX = 8;
15
+ /** Ignore sub-pixel overflow from rounding / borders. */
16
+ const OVERFLOW_TOLERANCE_PX = 1;
17
+
18
+ /**
19
+ * Whether a scroll container should show a "more content below" affordance.
20
+ * Visible only when content overflows and the viewport is still near the top.
21
+ */
22
+ export function shouldShowScrollHint(options: {
23
+ scrollHeight: number;
24
+ clientHeight: number;
25
+ scrollTop: number;
26
+ }): boolean {
27
+ const { scrollHeight, clientHeight, scrollTop } = options;
28
+ const overflowing = scrollHeight > clientHeight + OVERFLOW_TOLERANCE_PX;
29
+ const atTop = scrollTop <= TOP_THRESHOLD_PX;
30
+ return overflowing && atTop;
31
+ }
32
+
33
+ /**
34
+ * Measure only up to the first unrevealed fragment so
35
+ * the hint reflects currently revealed content, not future fragment steps.
36
+ */
37
+ function measureRevealedHeight(el: HTMLElement): number {
38
+ const firstUnrevealed = el.querySelector<HTMLElement>(
39
+ ".fragment:not(.visible)",
40
+ );
41
+ if (!firstUnrevealed) {
42
+ return el.scrollHeight;
43
+ }
44
+ const fragmentTop = firstUnrevealed.getBoundingClientRect().top;
45
+ return fragmentTop - el.getBoundingClientRect().top + el.scrollTop;
46
+ }
47
+
48
+ /**
49
+ * Tracks whether a scroll container is overflowing at the top, so we can
50
+ * surface a scroll affordance.
51
+ */
52
+ function useScrollHint(
53
+ scrollRef: RefObject<HTMLElement | null>,
54
+ contentRef: RefObject<HTMLElement | null>,
55
+ ): boolean {
56
+ const [showHint, setShowHint] = useState(false);
57
+
58
+ useEffect(() => {
59
+ const el = scrollRef.current;
60
+ const content = contentRef.current;
61
+ if (!el || !content) {
62
+ return;
63
+ }
64
+
65
+ let frame = 0;
66
+ const update = () => {
67
+ cancelAnimationFrame(frame);
68
+ frame = requestAnimationFrame(() => {
69
+ setShowHint(
70
+ shouldShowScrollHint({
71
+ scrollHeight: measureRevealedHeight(el),
72
+ clientHeight: el.clientHeight,
73
+ scrollTop: el.scrollTop,
74
+ }),
75
+ );
76
+ });
77
+ };
78
+
79
+ update();
80
+
81
+ const resizeObserver = new ResizeObserver(update);
82
+ resizeObserver.observe(el);
83
+ resizeObserver.observe(content);
84
+
85
+ // Fragments reveal via class changes (opacity/visibility, no layout shift),
86
+ // which the ResizeObserver can't see, so re-check on class mutations. Only
87
+ // attach on slides that have fragments to avoid reacting to unrelated class
88
+ // churn (e.g. animating widgets) on the common non-fragment slide.
89
+ const fragmentObserver = content.querySelector(".fragment")
90
+ ? new MutationObserver(update)
91
+ : null;
92
+ fragmentObserver?.observe(content, {
93
+ subtree: true,
94
+ attributes: true,
95
+ attributeFilter: ["class"],
96
+ });
97
+
98
+ el.addEventListener("scroll", update, { passive: true });
99
+
100
+ return () => {
101
+ cancelAnimationFrame(frame);
102
+ resizeObserver.disconnect();
103
+ fragmentObserver?.disconnect();
104
+ el.removeEventListener("scroll", update);
105
+ };
106
+ }, [scrollRef, contentRef]);
107
+
108
+ return showHint;
109
+ }
110
+
111
+ const ScrollHintOverlay = ({ className }: { className?: string }) => {
112
+ return (
113
+ <div
114
+ className={cn(
115
+ "pointer-events-none absolute inset-x-0 bottom-0 z-10 flex flex-col items-center",
116
+ "animate-in fade-in-0 duration-200",
117
+ className,
118
+ )}
119
+ data-testid="slide-scroll-hint"
120
+ aria-hidden={true}
121
+ >
122
+ <div className="h-16 w-full bg-linear-to-t from-background via-background/80 to-transparent" />
123
+ <div className="absolute bottom-3 flex items-center gap-1 rounded-full border border-border/60 bg-background/90 px-2.5 py-1 text-xs text-muted-foreground shadow-sm backdrop-blur-sm">
124
+ <ChevronDownIcon className="h-3.5 w-3.5" />
125
+ <span>Scroll for more</span>
126
+ </div>
127
+ </div>
128
+ );
129
+ };
130
+
131
+ /**
132
+ * Full-height scroll container for a slide. Shows a dismissible "Scroll for
133
+ * more" cue when content overflows the frame (easy to miss in fullscreen when
134
+ * OS overlay scrollbars are hidden).
135
+ */
136
+ export const SlideScrollContainer = ({
137
+ children,
138
+ className,
139
+ }: {
140
+ children: ReactNode;
141
+ className?: string;
142
+ }) => {
143
+ const scrollRef = useRef<HTMLDivElement>(null);
144
+ const contentRef = useRef<HTMLDivElement>(null);
145
+ const showHint = useScrollHint(scrollRef, contentRef);
146
+
147
+ return (
148
+ <div className={cn("relative h-full w-full", className)}>
149
+ <div
150
+ ref={scrollRef}
151
+ className="h-full w-full overflow-auto"
152
+ data-testid="slide-scroll-container"
153
+ >
154
+ <div ref={contentRef} className="flex w-full min-h-full">
155
+ {children}
156
+ </div>
157
+ </div>
158
+ {showHint && <ScrollHintOverlay />}
159
+ </div>
160
+ );
161
+ };
@@ -195,7 +195,7 @@ export const OnBlurredInput = React.forwardRef<
195
195
  onChange={(event) => setInternalValue(event.target.value)}
196
196
  onBlur={() => setValue(internalValue || "")}
197
197
  onKeyDown={(event) => {
198
- if (event.key !== "Enter") {
198
+ if (event.key !== "Enter" || event.shiftKey) {
199
199
  return;
200
200
  }
201
201
  setValue(internalValue || "");
@@ -1,8 +1,15 @@
1
1
  /* Copyright 2026 Marimo. All rights reserved. */
2
2
 
3
- import { defaultKeymap as originalDefaultKeymap } from "@codemirror/commands";
3
+ import {
4
+ copyLineDown,
5
+ copyLineUp,
6
+ defaultKeymap as originalDefaultKeymap,
7
+ } from "@codemirror/commands";
4
8
  import { describe, expect, it } from "vitest";
5
- import { HotkeyProvider } from "@/core/hotkeys/hotkeys";
9
+ import {
10
+ HotkeyProvider,
11
+ OverridingHotkeyProvider,
12
+ } from "@/core/hotkeys/hotkeys";
6
13
  import { visibleForTesting } from "../keymaps";
7
14
 
8
15
  const { defaultKeymap, defaultVimKeymap, overrideKeymap, OVERRIDDEN_COMMANDS } =
@@ -40,4 +47,26 @@ describe("keymaps", () => {
40
47
  expect(keys.some((k) => k.run === command)).toBe(true);
41
48
  }
42
49
  });
50
+
51
+ it.each([
52
+ ["cell.copyLineUp", copyLineUp, "Alt-Shift-ArrowUp"],
53
+ ["cell.copyLineDown", copyLineDown, "Alt-Shift-ArrowDown"],
54
+ ] as const)(
55
+ "%s is bound to the configured hotkey, not CodeMirror's default",
56
+ (action, command, defaultKey) => {
57
+ // Without an override, the binding matches CodeMirror's default key.
58
+ expect(
59
+ overrideKeymap(HotkeyProvider.create()).find((k) => k.run === command)
60
+ ?.key,
61
+ ).toBe(defaultKey);
62
+
63
+ // With an override, the binding follows the user's configured key.
64
+ // This is what `editable: true` on these hotkeys promises.
65
+ expect(
66
+ overrideKeymap(
67
+ new OverridingHotkeyProvider({ [action]: "Ctrl-d" }),
68
+ ).find((k) => k.run === command)?.key,
69
+ ).toBe("Ctrl-d");
70
+ },
71
+ );
43
72
  });