@marimo-team/frontend 0.25.3-dev82 → 0.25.3-dev86
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.
- package/dist/assets/{CellStatus-DXroYLOP.js → CellStatus-DYRvLOiP.js} +1 -1
- package/dist/assets/{ErrorBoundary-DaQ9THH_.js → ErrorBoundary-BKQo68Df.js} +2 -2
- package/dist/assets/{JsonOutput-VxzHyItt.js → JsonOutput-DryOywaN.js} +5 -5
- package/dist/assets/{MarimoErrorOutput-D-hLaDA-.js → MarimoErrorOutput-XrTcOYTI.js} +1 -1
- package/dist/assets/{RenderHTML-DjOEZL25.js → RenderHTML-DaVbCAsb.js} +1 -1
- package/dist/assets/{RunButton-CuhNTo5_.js → RunButton-D3ScLvwb.js} +1 -1
- package/dist/assets/{add-cell-with-ai-BbVntTh7.js → add-cell-with-ai-BKk0pvon.js} +1 -1
- package/dist/assets/{add-connection-dialog-Qf63aKV6.js → add-connection-dialog-CNIwGJVL.js} +1 -1
- package/dist/assets/{agent-panel-DMOfF3qF.js → agent-panel-6t16-QG0.js} +1 -1
- package/dist/assets/{ai-model-dropdown-B15l0R62.js → ai-model-dropdown-prnCk5hq.js} +1 -1
- package/dist/assets/{app-config-button-BIMB8qjD.js → app-config-button-O_yI5nzB.js} +1 -1
- package/dist/assets/{blob-De9GR2s6.js → blob-FjxwDE1i.js} +1 -1
- package/dist/assets/{cell-editor-Di0cSFhC.js → cell-editor-CM_kQ_VW.js} +1 -1
- package/dist/assets/{cell-link-DR3W2HC7.js → cell-link-D8EbRFJJ.js} +1 -1
- package/dist/assets/{cells-styV7IX8.js → cells-DfF1DLV_.js} +1 -1
- package/dist/assets/{chat-display-BzdYdN39.js → chat-display-BtQI9LeB.js} +1 -1
- package/dist/assets/{chat-panel-De3YzkSF.js → chat-panel-tuUN0wet.js} +1 -1
- package/dist/assets/{chat-ui-NTNapdtP.js → chat-ui-Bb-HxAv4.js} +1 -1
- package/dist/assets/{code-visibility-csc3tgA0.js → code-visibility-ny5n3-GH.js} +1 -1
- package/dist/assets/{column-preview-DkH-oD8f.js → column-preview-CqFSXTtf.js} +1 -1
- package/dist/assets/{command-palette-Dg_xstl0.js → command-palette-CkDj29px.js} +1 -1
- package/dist/assets/{common-D7VAS6rd.js → common-D2oGA09G.js} +1 -1
- package/dist/assets/{components-CNmvC781.js → components-1zsrzJ_K.js} +1 -1
- package/dist/assets/config-BWKfkUWA.js +1 -0
- package/dist/assets/{config-BbRbT9Ww.js → config-CP8Y8_R0.js} +1 -1
- package/dist/assets/{datasource-C3yfeDa2.js → datasource-rV_-mqbc.js} +1 -1
- package/dist/assets/{dependency-graph-panel-CjoA6L0w.js → dependency-graph-panel-By-cv0Nw.js} +1 -1
- package/dist/assets/{documentation-panel-DHWFOiG1.js → documentation-panel-DHMjvOPt.js} +1 -1
- package/dist/assets/{download-BrCpRZKN.js → download-CMTzX3rG.js} +1 -1
- package/dist/assets/{edit-page-BPMo_x-T.js → edit-page-Bgkov1Jy.js} +4 -4
- package/dist/assets/{error-entries-BkEAmmnD.js → error-entries-BGCjRkVy.js} +1 -1
- package/dist/assets/{error-panel-BhTfPA_i.js → error-panel-BDTFHLEt.js} +1 -1
- package/dist/assets/{feedback-button-D9InK87G.js → feedback-button-C-ScLxY2.js} +1 -1
- package/dist/assets/feedback-button-CLRAP9DE.js +1 -0
- package/dist/assets/{file-explorer-panel-BSd_ZbUC.js → file-explorer-panel-B9jBSwhj.js} +1 -1
- package/dist/assets/{file-name-input-RHURglL_.js → file-name-input-DIvrcC82.js} +1 -1
- package/dist/assets/{floating-outline-Cm49rYYe.js → floating-outline-DJnZ45VA.js} +1 -1
- package/dist/assets/{gallery-page-DdMdOtdv.js → gallery-page-CHJD1mdU.js} +1 -1
- package/dist/assets/{glide-data-editor-eiSlEUyR.js → glide-data-editor-BsE9Em-a.js} +1 -1
- package/dist/assets/{home-page-DwWvBB0R.js → home-page-CCbdig5p.js} +1 -1
- package/dist/assets/{hooks-CHeUZHPp.js → hooks-yjSoxKTw.js} +1 -1
- package/dist/assets/{index-DckSn2w6.js → index-0pnwTVch.js} +5 -5
- package/dist/assets/index-BnhSnwvB.css +2 -0
- package/dist/assets/{layout-_lp06T0C.js → layout-B2MJFBFp.js} +3 -3
- package/dist/assets/{logs-panel-CAAk72Gb.js → logs-panel-B34vxRV7.js} +1 -1
- package/dist/assets/{markdown-renderer-CAyCgogC.js → markdown-renderer-CkN_xAjW.js} +1 -1
- package/dist/assets/{name-cell-input-DA8hU9XT.js → name-cell-input-eyYneuGK.js} +1 -1
- package/dist/assets/{outline-panel-Cc5oQ4LL.js → outline-panel-rpjpMDYX.js} +1 -1
- package/dist/assets/{packages-panel-DTfKez6I.js → packages-panel-gXdFgCro.js} +1 -1
- package/dist/assets/{panel-accordion-state-BZHex38W.js → panel-accordion-state-CImIsdc8.js} +1 -1
- package/dist/assets/{panels-DU-SaRHy.js → panels-DI8gAi31.js} +1 -1
- package/dist/assets/{process-output-CrDc_O1D.js → process-output-BClx23Jv.js} +1 -1
- package/dist/assets/{readonly-code-display-BR-_jUj8.js → readonly-code-display-JC_YsLL_.js} +1 -1
- package/dist/assets/{readonly-python-code-D7jcy9ds.js → readonly-python-code-DeBED4OB.js} +1 -1
- package/dist/assets/{reveal-component-eLftBwOe.js → reveal-component-BoGgZrdo.js} +1 -1
- package/dist/assets/{run-page-mURyATDJ.js → run-page-DnvqkxCy.js} +1 -1
- package/dist/assets/{runtime-details-state-hb7A1jgR.js → runtime-details-state-7JHj3efu.js} +1 -1
- package/dist/assets/{scratchpad-panel-B_gEnoqj.js → scratchpad-panel-DZDmNIPw.js} +1 -1
- package/dist/assets/{session-panel-DmDjaPsN.js → session-panel-B1QB5LN7.js} +1 -1
- package/dist/assets/{snippets-panel-o9KL5jMi.js → snippets-panel-BNjtDbnR.js} +1 -1
- package/dist/assets/{state-BkAqJoW7.js → state-DUP4ToPd.js} +1 -1
- package/dist/assets/{state-Os7LSUUZ.js → state-DhaPGGBO.js} +1 -1
- package/dist/assets/{terminal-BI85khrS.js → terminal-BwLC9HS5.js} +1 -1
- package/dist/assets/{tracing-AsNGuDsh.js → tracing-Ch4LR62-.js} +1 -1
- package/dist/assets/{tracing-panel-CBcmea6L.js → tracing-panel-B8fX4PhX.js} +2 -2
- package/dist/assets/{tree-actions-Cx5EDpA6.js → tree-actions-Zg3Ycse4.js} +1 -1
- package/dist/assets/{useAddCell-uJJYtgwa.js → useAddCell-BX9lq_0J.js} +1 -1
- package/dist/assets/{useBoolean-8wlrTeus.js → useBoolean-WABMLM83.js} +1 -1
- package/dist/assets/{useCellActionButton-CmQ-OcPa.js → useCellActionButton-D7Ruyes-.js} +1 -1
- package/dist/assets/{useDeleteCell-CXgOCDqY.js → useDeleteCell-AbkQI6-c.js} +1 -1
- package/dist/assets/{useDependencyPanelTab-Ch6gm7uM.js → useDependencyPanelTab-B0b2KkvS.js} +1 -1
- package/dist/assets/{useInstallPackage-CYQoMdMQ.js → useInstallPackage-CWcPxSLP.js} +1 -1
- package/dist/assets/{useNotebookActions-DGShHZ_1.js → useNotebookActions-DJuAUAGI.js} +1 -1
- package/dist/assets/{useRunCells-DAfDmOMC.js → useRunCells-CV81rge-.js} +1 -1
- package/dist/assets/{useSplitCell-NLULgcq7.js → useSplitCell-BTwCB3JS.js} +1 -1
- package/dist/assets/{vega-component-BKJU3uaB.js → vega-component-CY6HmM06.js} +1 -1
- package/dist/index.html +25 -25
- package/package.json +1 -1
- package/src/components/data-table/__tests__/columns.test.tsx +105 -0
- package/src/components/data-table/__tests__/search-highlight.test.tsx +109 -0
- package/src/components/data-table/__tests__/url-detector.test.tsx +74 -0
- package/src/components/data-table/columns.tsx +43 -45
- package/src/components/data-table/data-table.tsx +7 -1
- package/src/components/data-table/search-highlight.tsx +79 -0
- package/src/components/data-table/types.ts +1 -0
- package/src/components/data-table/url-detector.tsx +49 -9
- package/src/core/runtime/__tests__/createWsUrl.test.ts +77 -7
- package/src/core/runtime/__tests__/runtime.test.ts +13 -5
- package/src/core/runtime/runtime.ts +23 -27
- package/src/css/globals.css +2 -0
- package/dist/assets/config-BQT0uYUo.js +0 -1
- package/dist/assets/feedback-button-BAcClv7c.js +0 -1
- package/dist/assets/index-BdsOMJac.css +0 -2
|
@@ -41,6 +41,7 @@ import {
|
|
|
41
41
|
isNumericType,
|
|
42
42
|
} from "./types";
|
|
43
43
|
import { SentinelCell, WhitespaceMarkers } from "./sentinel-cell";
|
|
44
|
+
import { highlightMatches } from "./search-highlight";
|
|
44
45
|
import { detectSentinel, splitLeadingTrailingWhitespace } from "./utils";
|
|
45
46
|
import { uniformSample } from "./uniformSample";
|
|
46
47
|
import { MarkdownUrlDetector, UrlDetector } from "./url-detector";
|
|
@@ -278,7 +279,7 @@ export function generateColumns<T>({
|
|
|
278
279
|
);
|
|
279
280
|
},
|
|
280
281
|
|
|
281
|
-
cell: ({ column, renderValue, getValue, cell }) => {
|
|
282
|
+
cell: ({ column, renderValue, getValue, cell, table }) => {
|
|
282
283
|
function selectCell() {
|
|
283
284
|
if (selection !== "single-cell" && selection !== "multi-cell") {
|
|
284
285
|
return;
|
|
@@ -310,6 +311,7 @@ export function generateColumns<T>({
|
|
|
310
311
|
getValue,
|
|
311
312
|
selectCell,
|
|
312
313
|
cellStyles,
|
|
314
|
+
highlightPattern: table.options.meta?.highlightPattern,
|
|
313
315
|
});
|
|
314
316
|
|
|
315
317
|
// Row headers are bold
|
|
@@ -586,12 +588,14 @@ function renderDate({
|
|
|
586
588
|
dtype,
|
|
587
589
|
displayValue,
|
|
588
590
|
locale,
|
|
591
|
+
highlightPattern,
|
|
589
592
|
}: {
|
|
590
593
|
value: Date;
|
|
591
594
|
dataType?: DataType;
|
|
592
595
|
dtype?: string;
|
|
593
596
|
displayValue?: string;
|
|
594
597
|
locale: string;
|
|
598
|
+
highlightPattern?: RegExp;
|
|
595
599
|
}): React.ReactNode {
|
|
596
600
|
const type = dataType === "date" ? "date" : "datetime";
|
|
597
601
|
const timezone = extractTimezone(dtype);
|
|
@@ -600,7 +604,7 @@ function renderDate({
|
|
|
600
604
|
|
|
601
605
|
return (
|
|
602
606
|
<DatePopover date={value} type={type}>
|
|
603
|
-
{exactValue}
|
|
607
|
+
{highlightMatches(exactValue, highlightPattern)}
|
|
604
608
|
</DatePopover>
|
|
605
609
|
);
|
|
606
610
|
}
|
|
@@ -611,12 +615,14 @@ export function renderCellValue<TData, TValue>({
|
|
|
611
615
|
getValue,
|
|
612
616
|
selectCell,
|
|
613
617
|
cellStyles,
|
|
618
|
+
highlightPattern,
|
|
614
619
|
}: {
|
|
615
620
|
column: Column<TData, TValue>;
|
|
616
621
|
renderValue: () => TValue | null;
|
|
617
622
|
getValue: () => TValue;
|
|
618
623
|
selectCell?: () => void;
|
|
619
624
|
cellStyles?: string;
|
|
625
|
+
highlightPattern?: RegExp;
|
|
620
626
|
}) {
|
|
621
627
|
const value = getValue();
|
|
622
628
|
const format = column.getColumnFormatting?.();
|
|
@@ -626,6 +632,28 @@ export function renderCellValue<TData, TValue>({
|
|
|
626
632
|
|
|
627
633
|
const isWrapped = column.getColumnWrapping?.() === "wrap";
|
|
628
634
|
|
|
635
|
+
const textCell = (text: string) => (
|
|
636
|
+
<div onClick={selectCell} className={cellStyles}>
|
|
637
|
+
{highlightMatches(text, highlightPattern)}
|
|
638
|
+
</div>
|
|
639
|
+
);
|
|
640
|
+
const dateCell = (date: Date, displayValue?: string) => (
|
|
641
|
+
<div onClick={selectCell} className={cellStyles}>
|
|
642
|
+
<WithLocale>
|
|
643
|
+
{(locale) =>
|
|
644
|
+
renderDate({
|
|
645
|
+
value: date,
|
|
646
|
+
dataType,
|
|
647
|
+
dtype,
|
|
648
|
+
displayValue,
|
|
649
|
+
locale,
|
|
650
|
+
highlightPattern,
|
|
651
|
+
})
|
|
652
|
+
}
|
|
653
|
+
</WithLocale>
|
|
654
|
+
</div>
|
|
655
|
+
);
|
|
656
|
+
|
|
629
657
|
// Sentinel values (null, whitespace, NaN, Infinity, NaT) rendered specially.
|
|
630
658
|
// Empty strings are left as-is
|
|
631
659
|
const sentinel = detectSentinel(value, dataType, dtype);
|
|
@@ -641,23 +669,10 @@ export function renderCellValue<TData, TValue>({
|
|
|
641
669
|
try {
|
|
642
670
|
const date = new Date(value);
|
|
643
671
|
if (!format || !isValid(date)) {
|
|
644
|
-
return (
|
|
645
|
-
<div onClick={selectCell} className={cellStyles}>
|
|
646
|
-
{value}
|
|
647
|
-
</div>
|
|
648
|
-
);
|
|
672
|
+
return textCell(value);
|
|
649
673
|
}
|
|
650
674
|
|
|
651
|
-
|
|
652
|
-
return (
|
|
653
|
-
<div onClick={selectCell} className={cellStyles}>
|
|
654
|
-
<WithLocale>
|
|
655
|
-
{(locale) =>
|
|
656
|
-
renderDate({ value: date, dataType, dtype, displayValue, locale })
|
|
657
|
-
}
|
|
658
|
-
</WithLocale>
|
|
659
|
-
</div>
|
|
660
|
-
);
|
|
675
|
+
return dateCell(date, String(column.applyColumnFormatting(value)));
|
|
661
676
|
} catch (error) {
|
|
662
677
|
Logger.error("Error parsing datetime, fallback to string", error);
|
|
663
678
|
}
|
|
@@ -673,17 +688,9 @@ export function renderCellValue<TData, TValue>({
|
|
|
673
688
|
}
|
|
674
689
|
|
|
675
690
|
// e.g. 2010-10-07 17:15:00
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
: undefined
|
|
679
|
-
return (
|
|
680
|
-
<div onClick={selectCell} className={cellStyles}>
|
|
681
|
-
<WithLocale>
|
|
682
|
-
{(locale) =>
|
|
683
|
-
renderDate({ value, dataType, dtype, displayValue, locale })
|
|
684
|
-
}
|
|
685
|
-
</WithLocale>
|
|
686
|
-
</div>
|
|
691
|
+
return dateCell(
|
|
692
|
+
value,
|
|
693
|
+
format ? String(column.applyColumnFormatting(value)) : undefined,
|
|
687
694
|
);
|
|
688
695
|
}
|
|
689
696
|
|
|
@@ -729,7 +736,7 @@ export function renderCellValue<TData, TValue>({
|
|
|
729
736
|
className={cn(cellStyles, isWrapped && COLUMN_WRAPPING_STYLES)}
|
|
730
737
|
>
|
|
731
738
|
<WhitespaceMarkers value={leading} />
|
|
732
|
-
<UrlDetector parts={parts} />
|
|
739
|
+
<UrlDetector parts={parts} highlightPattern={highlightPattern} />
|
|
733
740
|
<WhitespaceMarkers value={trailing} />
|
|
734
741
|
</div>
|
|
735
742
|
);
|
|
@@ -755,11 +762,7 @@ export function renderCellValue<TData, TValue>({
|
|
|
755
762
|
}
|
|
756
763
|
|
|
757
764
|
if (format) {
|
|
758
|
-
return (
|
|
759
|
-
<div onClick={selectCell} className={cellStyles}>
|
|
760
|
-
{column.applyColumnFormatting(value)}
|
|
761
|
-
</div>
|
|
762
|
-
);
|
|
765
|
+
return textCell(String(column.applyColumnFormatting(value)));
|
|
763
766
|
}
|
|
764
767
|
|
|
765
768
|
// Format to the correct locale
|
|
@@ -769,26 +772,19 @@ export function renderCellValue<TData, TValue>({
|
|
|
769
772
|
<LocaleNumber
|
|
770
773
|
value={value}
|
|
771
774
|
minFractionDigits={column.columnDef.meta?.minFractionDigits}
|
|
775
|
+
highlightPattern={highlightPattern}
|
|
772
776
|
/>
|
|
773
777
|
</div>
|
|
774
778
|
);
|
|
775
779
|
}
|
|
776
780
|
|
|
777
781
|
if (typeof value === "boolean") {
|
|
778
|
-
return (
|
|
779
|
-
<div onClick={selectCell} className={cellStyles}>
|
|
780
|
-
{value ? "True" : "False"}
|
|
781
|
-
</div>
|
|
782
|
-
);
|
|
782
|
+
return textCell(value ? "True" : "False");
|
|
783
783
|
}
|
|
784
784
|
|
|
785
785
|
if (isPrimitiveOrNullish(value)) {
|
|
786
786
|
const rendered = renderValue();
|
|
787
|
-
return (
|
|
788
|
-
<div onClick={selectCell} className={cellStyles}>
|
|
789
|
-
{rendered == null ? "" : String(rendered)}
|
|
790
|
-
</div>
|
|
791
|
-
);
|
|
787
|
+
return textCell(rendered == null ? "" : String(rendered));
|
|
792
788
|
}
|
|
793
789
|
|
|
794
790
|
const mimeValues = getMimeValues(value);
|
|
@@ -826,14 +822,16 @@ export function renderCellValue<TData, TValue>({
|
|
|
826
822
|
export const LocaleNumber = ({
|
|
827
823
|
value,
|
|
828
824
|
minFractionDigits,
|
|
825
|
+
highlightPattern,
|
|
829
826
|
}: {
|
|
830
827
|
value: number;
|
|
831
828
|
minFractionDigits?: number;
|
|
829
|
+
highlightPattern?: RegExp;
|
|
832
830
|
}) => {
|
|
833
831
|
const { locale } = useLocale();
|
|
834
832
|
const format = useNumberFormatter({
|
|
835
833
|
minimumFractionDigits: minFractionDigits,
|
|
836
834
|
maximumFractionDigits: maxFractionalDigits(locale),
|
|
837
835
|
});
|
|
838
|
-
return format.format(value);
|
|
836
|
+
return highlightMatches(format.format(value), highlightPattern);
|
|
839
837
|
};
|
|
@@ -58,6 +58,7 @@ import { useColumnPinning } from "./hooks/use-column-pinning";
|
|
|
58
58
|
import { useScrollContainerHeight } from "./hooks/use-scroll-container-height";
|
|
59
59
|
import { CellSelectionProvider } from "./range-focus/provider";
|
|
60
60
|
import { DataTableBody, renderTableHeader } from "./renderers";
|
|
61
|
+
import { buildHighlightPattern } from "./search-highlight";
|
|
61
62
|
import { TableBottomBar } from "./TableBottomBar";
|
|
62
63
|
import { TableTopBar } from "./TableTopBar";
|
|
63
64
|
import {
|
|
@@ -231,6 +232,11 @@ const DataTableInternal = <TData,>({
|
|
|
231
232
|
return idx + offset;
|
|
232
233
|
}
|
|
233
234
|
|
|
235
|
+
const highlightPattern = React.useMemo(
|
|
236
|
+
() => buildHighlightPattern(searchQuery),
|
|
237
|
+
[searchQuery],
|
|
238
|
+
);
|
|
239
|
+
|
|
234
240
|
const table = useReactTable<TData>({
|
|
235
241
|
_features: [
|
|
236
242
|
ColumnPinning,
|
|
@@ -245,7 +251,7 @@ const DataTableInternal = <TData,>({
|
|
|
245
251
|
],
|
|
246
252
|
data,
|
|
247
253
|
columns,
|
|
248
|
-
meta: { rawData },
|
|
254
|
+
meta: { rawData, highlightPattern },
|
|
249
255
|
getCoreRowModel: getCoreRowModel(),
|
|
250
256
|
// pagination
|
|
251
257
|
rowCount: totalRows === "too_many" ? undefined : totalRows,
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
/* Copyright 2026 Marimo. All rights reserved. */
|
|
2
|
+
|
|
3
|
+
import type { ReactNode } from "react";
|
|
4
|
+
|
|
5
|
+
/** Half-open `[start, end)` offsets into a string. */
|
|
6
|
+
export type MatchRange = readonly [start: number, end: number];
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Build the pattern that finds `query` in a display string. Returns
|
|
10
|
+
* `undefined` for an empty query. The query is matched as a literal,
|
|
11
|
+
* case-insensitive substring, which mirrors how the backend selects rows.
|
|
12
|
+
* The `u` flag selects Unicode simple case folding, the same folding the
|
|
13
|
+
* backend regex engines use.
|
|
14
|
+
*/
|
|
15
|
+
export function buildHighlightPattern(query?: string): RegExp | undefined {
|
|
16
|
+
if (!query) {
|
|
17
|
+
return undefined;
|
|
18
|
+
}
|
|
19
|
+
const escaped = query.replace(/[$()*+.?[\\\]^{|}]/g, "\\$&");
|
|
20
|
+
return new RegExp(escaped, "giu");
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Offsets of every match of `pattern` in `text`. */
|
|
24
|
+
export function findMatchRanges(text: string, pattern?: RegExp): MatchRange[] {
|
|
25
|
+
if (!pattern) {
|
|
26
|
+
return [];
|
|
27
|
+
}
|
|
28
|
+
const ranges: MatchRange[] = [];
|
|
29
|
+
for (const match of text.matchAll(pattern)) {
|
|
30
|
+
ranges.push([match.index, match.index + match[0].length]);
|
|
31
|
+
}
|
|
32
|
+
return ranges;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Wrap the parts of `text` covered by `ranges` in `<mark>` elements.
|
|
37
|
+
* `ranges` are offsets into a larger string in which `text` starts at
|
|
38
|
+
* `offset`, so one set of ranges can be shared across adjacent segments.
|
|
39
|
+
* Returns `text` unchanged when no range overlaps it.
|
|
40
|
+
*/
|
|
41
|
+
export function highlightRanges(
|
|
42
|
+
text: string,
|
|
43
|
+
ranges: readonly MatchRange[],
|
|
44
|
+
offset = 0,
|
|
45
|
+
): ReactNode {
|
|
46
|
+
const segments: ReactNode[] = [];
|
|
47
|
+
let cursor = 0;
|
|
48
|
+
for (const [rangeStart, rangeEnd] of ranges) {
|
|
49
|
+
const start = Math.max(rangeStart - offset, cursor);
|
|
50
|
+
const end = Math.min(rangeEnd - offset, text.length);
|
|
51
|
+
if (end <= start) {
|
|
52
|
+
continue;
|
|
53
|
+
}
|
|
54
|
+
if (start > cursor) {
|
|
55
|
+
segments.push(text.slice(cursor, start));
|
|
56
|
+
}
|
|
57
|
+
segments.push(
|
|
58
|
+
<mark key={start} className="bg-search-highlight text-inherit">
|
|
59
|
+
{text.slice(start, end)}
|
|
60
|
+
</mark>,
|
|
61
|
+
);
|
|
62
|
+
cursor = end;
|
|
63
|
+
}
|
|
64
|
+
if (segments.length === 0) {
|
|
65
|
+
return text;
|
|
66
|
+
}
|
|
67
|
+
if (cursor < text.length) {
|
|
68
|
+
segments.push(text.slice(cursor));
|
|
69
|
+
}
|
|
70
|
+
return segments;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Wrap every match of `pattern` in `text` in a `<mark>` element. Returns
|
|
75
|
+
* `text` unchanged when `pattern` is absent or does not match.
|
|
76
|
+
*/
|
|
77
|
+
export function highlightMatches(text: string, pattern?: RegExp): ReactNode {
|
|
78
|
+
return highlightRanges(text, findMatchRanges(text, pattern));
|
|
79
|
+
}
|
|
@@ -6,6 +6,7 @@ import type { DataType } from "@/core/kernel/messages";
|
|
|
6
6
|
declare module "@tanstack/react-table" {
|
|
7
7
|
interface TableMeta<TData extends RowData> {
|
|
8
8
|
rawData?: TData[]; // raw data for filtering/copying (present only if format_mapping is provided)
|
|
9
|
+
highlightPattern?: RegExp; // built from the committed search query that produced the current rows
|
|
9
10
|
}
|
|
10
11
|
}
|
|
11
12
|
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/* Copyright 2026 Marimo. All rights reserved. */
|
|
2
2
|
|
|
3
3
|
import { marked } from "marked";
|
|
4
|
-
import { useState } from "react";
|
|
4
|
+
import { Fragment, type ReactNode, useState } from "react";
|
|
5
5
|
import { MarkdownRenderer } from "@/components/markdown/markdown-renderer";
|
|
6
6
|
import {
|
|
7
7
|
Popover,
|
|
@@ -10,13 +10,20 @@ import {
|
|
|
10
10
|
} from "@/components/ui/popover";
|
|
11
11
|
import { Events } from "@/utils/events";
|
|
12
12
|
import type { ContentPart } from "@/utils/url-parser";
|
|
13
|
+
import { findMatchRanges, highlightRanges } from "./search-highlight";
|
|
13
14
|
|
|
14
|
-
const ImageWithFallback = ({
|
|
15
|
+
const ImageWithFallback = ({
|
|
16
|
+
url,
|
|
17
|
+
fallbackLabel,
|
|
18
|
+
}: {
|
|
19
|
+
url: string;
|
|
20
|
+
fallbackLabel?: ReactNode;
|
|
21
|
+
}) => {
|
|
15
22
|
const [error, setError] = useState(false);
|
|
16
23
|
const [open, setOpen] = useState(false);
|
|
17
24
|
|
|
18
25
|
if (error) {
|
|
19
|
-
return <URLAnchor url={url}
|
|
26
|
+
return <URLAnchor url={url}>{fallbackLabel}</URLAnchor>;
|
|
20
27
|
}
|
|
21
28
|
|
|
22
29
|
return (
|
|
@@ -93,21 +100,54 @@ export const MarkdownUrlDetector = ({
|
|
|
93
100
|
return <UrlDetector parts={parts} />;
|
|
94
101
|
};
|
|
95
102
|
|
|
96
|
-
export const UrlDetector = ({
|
|
103
|
+
export const UrlDetector = ({
|
|
104
|
+
parts,
|
|
105
|
+
highlightPattern,
|
|
106
|
+
}: {
|
|
107
|
+
parts: ContentPart[];
|
|
108
|
+
highlightPattern?: RegExp;
|
|
109
|
+
}) => {
|
|
110
|
+
// Match against the whole string so a query that spans a text part and
|
|
111
|
+
// a link is still marked. Each part then takes its slice of the ranges.
|
|
112
|
+
const ranges = highlightPattern
|
|
113
|
+
? findMatchRanges(parts.map(partText).join(""), highlightPattern)
|
|
114
|
+
: [];
|
|
115
|
+
|
|
116
|
+
let offset = 0;
|
|
97
117
|
const markup = parts.map((part, idx) => {
|
|
118
|
+
const text = partText(part);
|
|
119
|
+
const label = highlightRanges(text, ranges, offset);
|
|
120
|
+
offset += text.length;
|
|
121
|
+
|
|
98
122
|
if (part.type === "url") {
|
|
99
|
-
return
|
|
123
|
+
return (
|
|
124
|
+
<URLAnchor key={idx} url={part.url}>
|
|
125
|
+
{label}
|
|
126
|
+
</URLAnchor>
|
|
127
|
+
);
|
|
100
128
|
}
|
|
101
129
|
if (part.type === "image") {
|
|
102
|
-
return
|
|
130
|
+
return (
|
|
131
|
+
<ImageWithFallback key={idx} url={part.url} fallbackLabel={label} />
|
|
132
|
+
);
|
|
103
133
|
}
|
|
104
|
-
return
|
|
134
|
+
return <Fragment key={idx}>{label}</Fragment>;
|
|
105
135
|
});
|
|
106
136
|
|
|
107
137
|
return markup;
|
|
108
138
|
};
|
|
109
139
|
|
|
110
|
-
|
|
140
|
+
function partText(part: ContentPart): string {
|
|
141
|
+
return part.type === "text" ? part.value : part.url;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const URLAnchor = ({
|
|
145
|
+
url,
|
|
146
|
+
children,
|
|
147
|
+
}: {
|
|
148
|
+
url: string;
|
|
149
|
+
children?: ReactNode;
|
|
150
|
+
}) => {
|
|
111
151
|
return (
|
|
112
152
|
<a
|
|
113
153
|
href={url}
|
|
@@ -116,7 +156,7 @@ const URLAnchor = ({ url }: { url: string }) => {
|
|
|
116
156
|
onClick={Events.stopPropagation()}
|
|
117
157
|
className="text-link hover:underline"
|
|
118
158
|
>
|
|
119
|
-
{url}
|
|
159
|
+
{children ?? url}
|
|
120
160
|
</a>
|
|
121
161
|
);
|
|
122
162
|
};
|
|
@@ -13,7 +13,9 @@ describe("RuntimeManager.getWsURL", () => {
|
|
|
13
13
|
});
|
|
14
14
|
const sessionId = "1234" as SessionId;
|
|
15
15
|
const url = runtime.getWsURL(sessionId);
|
|
16
|
-
expect(url.toString()).toBe(
|
|
16
|
+
expect(url.toString()).toBe(
|
|
17
|
+
"wss://marimo.app/ws?query_params=&session_id=1234",
|
|
18
|
+
);
|
|
17
19
|
expect(url.searchParams.get(KnownQueryParams.sessionId)).toBe(sessionId);
|
|
18
20
|
});
|
|
19
21
|
|
|
@@ -24,7 +26,9 @@ describe("RuntimeManager.getWsURL", () => {
|
|
|
24
26
|
});
|
|
25
27
|
const sessionId = "1234" as SessionId;
|
|
26
28
|
const url = runtime.getWsURL(sessionId);
|
|
27
|
-
expect(url.toString()).toBe(
|
|
29
|
+
expect(url.toString()).toBe(
|
|
30
|
+
"ws://marimo.app/ws?query_params=&session_id=1234",
|
|
31
|
+
);
|
|
28
32
|
expect(url.searchParams.get(KnownQueryParams.sessionId)).toBe(sessionId);
|
|
29
33
|
});
|
|
30
34
|
|
|
@@ -35,7 +39,9 @@ describe("RuntimeManager.getWsURL", () => {
|
|
|
35
39
|
});
|
|
36
40
|
const sessionId = "1234" as SessionId;
|
|
37
41
|
const url = runtime.getWsURL(sessionId);
|
|
38
|
-
expect(url.toString()).toBe(
|
|
42
|
+
expect(url.toString()).toBe(
|
|
43
|
+
"ws://marimo.app/nested/ws?query_params=&session_id=1234",
|
|
44
|
+
);
|
|
39
45
|
expect(url.searchParams.get(KnownQueryParams.sessionId)).toBe(sessionId);
|
|
40
46
|
});
|
|
41
47
|
|
|
@@ -48,7 +54,7 @@ describe("RuntimeManager.getWsURL", () => {
|
|
|
48
54
|
const sessionId = "1234" as SessionId;
|
|
49
55
|
const url = runtime.getWsURL(sessionId);
|
|
50
56
|
expect(url.toString()).toBe(
|
|
51
|
-
"ws://marimo.app/nested/ws?foo=bar&file=test.py&session_id=1234",
|
|
57
|
+
"ws://marimo.app/nested/ws?foo=bar&file=test.py&query_params=foo%3Dbar%26file%3Dtest.py&session_id=1234",
|
|
52
58
|
);
|
|
53
59
|
expect(url.searchParams.get(KnownQueryParams.sessionId)).toBe(sessionId);
|
|
54
60
|
});
|
|
@@ -62,11 +68,75 @@ describe("RuntimeManager.getWsURL", () => {
|
|
|
62
68
|
const sessionId = "1234" as SessionId;
|
|
63
69
|
const url = runtime.getWsURL(sessionId);
|
|
64
70
|
expect(url.toString()).toBe(
|
|
65
|
-
"ws://marimo.app/ws?file=test.py&
|
|
71
|
+
"ws://marimo.app/ws?file=test.py&query_params=file%3Dtest.py%26search%3Dtest%26other%3Dlast&session_id=1234",
|
|
66
72
|
);
|
|
67
73
|
expect(url.searchParams.get("file")).toBe("test.py");
|
|
68
|
-
expect(url.searchParams.get("
|
|
69
|
-
|
|
74
|
+
expect(url.searchParams.get("query_params")).toBe(
|
|
75
|
+
"file=test.py&search=test&other=last",
|
|
76
|
+
);
|
|
70
77
|
expect(url.searchParams.get(KnownQueryParams.sessionId)).toBe(sessionId);
|
|
71
78
|
});
|
|
72
79
|
});
|
|
80
|
+
|
|
81
|
+
describe.each(["getWsURL", "getSseURL"] as const)(
|
|
82
|
+
"RuntimeManager.%s",
|
|
83
|
+
(method) => {
|
|
84
|
+
it("preserves runtime parameters and repeated page values", () => {
|
|
85
|
+
window.history.pushState({}, "", "/?tag=page-one&tag=page-two");
|
|
86
|
+
const runtime = new RuntimeManager(
|
|
87
|
+
{
|
|
88
|
+
url: `${window.location.origin}/nested?proxy_token=secret&tag=base&base=one&base=two`,
|
|
89
|
+
lazy: true,
|
|
90
|
+
},
|
|
91
|
+
true,
|
|
92
|
+
);
|
|
93
|
+
const url = runtime[method]("session" as SessionId);
|
|
94
|
+
expect([...url.searchParams]).toEqual([
|
|
95
|
+
["proxy_token", "secret"],
|
|
96
|
+
["tag", "base"],
|
|
97
|
+
["base", "one"],
|
|
98
|
+
["base", "two"],
|
|
99
|
+
[
|
|
100
|
+
"query_params",
|
|
101
|
+
"proxy_token=secret&base=one&base=two&tag=page-one&tag=page-two",
|
|
102
|
+
],
|
|
103
|
+
["session_id", "session"],
|
|
104
|
+
]);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it("keeps notebook parameters out of the stream options", () => {
|
|
108
|
+
const notebookParams = new URLSearchParams([
|
|
109
|
+
["types", "scatter"],
|
|
110
|
+
["cursor", "page-2"],
|
|
111
|
+
["run_id", "experiment"],
|
|
112
|
+
["query_params", "nested=value&other=1"],
|
|
113
|
+
["tag", "one"],
|
|
114
|
+
["tag", "two"],
|
|
115
|
+
["empty", ""],
|
|
116
|
+
["text", "a+b & café"],
|
|
117
|
+
["file", "test.py"],
|
|
118
|
+
["kiosk", "true"],
|
|
119
|
+
]);
|
|
120
|
+
window.history.pushState(
|
|
121
|
+
{},
|
|
122
|
+
"",
|
|
123
|
+
`/?${notebookParams}&session_id=page-session`,
|
|
124
|
+
);
|
|
125
|
+
const runtime = new RuntimeManager(
|
|
126
|
+
{ url: window.location.origin, lazy: true },
|
|
127
|
+
true,
|
|
128
|
+
);
|
|
129
|
+
const url = runtime[method]("actual-session" as SessionId);
|
|
130
|
+
|
|
131
|
+
expect(Object.fromEntries(url.searchParams)).toEqual({
|
|
132
|
+
file: "test.py",
|
|
133
|
+
kiosk: "true",
|
|
134
|
+
session_id: "actual-session",
|
|
135
|
+
query_params: notebookParams.toString(),
|
|
136
|
+
});
|
|
137
|
+
expect([
|
|
138
|
+
...new URLSearchParams(url.searchParams.get("query_params") ?? ""),
|
|
139
|
+
]).toEqual([...notebookParams]);
|
|
140
|
+
});
|
|
141
|
+
},
|
|
142
|
+
);
|
|
@@ -249,7 +249,8 @@ describe("RuntimeManager", () => {
|
|
|
249
249
|
const url = runtime.getSseURL("s_123" as SessionId);
|
|
250
250
|
|
|
251
251
|
expect(url.searchParams.get("access_token")).toBeNull();
|
|
252
|
-
expect(url.searchParams.get("
|
|
252
|
+
expect(url.searchParams.get("query_params")).toBe("foo=bar");
|
|
253
|
+
expect(url.toString()).not.toContain("page-token");
|
|
253
254
|
} finally {
|
|
254
255
|
window.history.pushState({}, "", original);
|
|
255
256
|
}
|
|
@@ -669,9 +670,16 @@ describe("RuntimeManager", () => {
|
|
|
669
670
|
expect(httpUrl.searchParams.get("base_param")).toBe("existing");
|
|
670
671
|
|
|
671
672
|
// Should preserve all window-level query params (including custom ones)
|
|
672
|
-
expect(
|
|
673
|
-
|
|
674
|
-
|
|
673
|
+
expect(
|
|
674
|
+
Object.fromEntries(
|
|
675
|
+
new URLSearchParams(wsUrl.searchParams.get("query_params") ?? ""),
|
|
676
|
+
),
|
|
677
|
+
).toEqual({
|
|
678
|
+
base_param: "existing",
|
|
679
|
+
custom_param: "value123",
|
|
680
|
+
user_token: "abc",
|
|
681
|
+
theme: "dark",
|
|
682
|
+
});
|
|
675
683
|
expect(httpUrl.searchParams.get("custom_param")).toBe("value123");
|
|
676
684
|
expect(httpUrl.searchParams.get("user_token")).toBe("abc");
|
|
677
685
|
expect(httpUrl.searchParams.get("theme")).toBe("dark");
|
|
@@ -738,7 +746,7 @@ describe("RuntimeManager", () => {
|
|
|
738
746
|
const sessionId = "test" as SessionId;
|
|
739
747
|
|
|
740
748
|
expect(runtime.getWsURL(sessionId).toString()).toMatchInlineSnapshot(
|
|
741
|
-
`"blob:https://example.com/12345678-1234-1234-1234-123456789abc?session_id=test"`,
|
|
749
|
+
`"blob:https://example.com/12345678-1234-1234-1234-123456789abc?query_params=&session_id=test"`,
|
|
742
750
|
);
|
|
743
751
|
expect(Logger.warn).toHaveBeenCalledOnce();
|
|
744
752
|
});
|
|
@@ -128,33 +128,32 @@ export class RuntimeManager {
|
|
|
128
128
|
return asWsUrl(this.appendCrossOriginAuth(url).toString());
|
|
129
129
|
}
|
|
130
130
|
|
|
131
|
-
/**
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
const
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
return searchParams;
|
|
131
|
+
/** Keep notebook page parameters separate from connection options. */
|
|
132
|
+
private getSessionURL(path: string, sessionId: SessionId): URL {
|
|
133
|
+
const url = this.formatHttpURL({ path });
|
|
134
|
+
const notebookParams = new URLSearchParams(this.httpURL.search);
|
|
135
|
+
const pageParams = new URLSearchParams(window.location.search);
|
|
136
|
+
for (const key of pageParams.keys()) {
|
|
137
|
+
notebookParams.delete(key);
|
|
138
|
+
}
|
|
139
|
+
for (const [key, value] of pageParams) {
|
|
140
|
+
notebookParams.append(key, value);
|
|
141
|
+
}
|
|
142
|
+
// These parameters are internal and are not exposed by mo.query_params().
|
|
143
|
+
notebookParams.delete(KnownQueryParams.accessToken);
|
|
144
|
+
notebookParams.delete("refresh_token");
|
|
145
|
+
notebookParams.delete(KnownQueryParams.sessionId);
|
|
146
|
+
url.searchParams.set("query_params", notebookParams.toString());
|
|
147
|
+
url.searchParams.set(KnownQueryParams.sessionId, sessionId);
|
|
148
|
+
return url;
|
|
150
149
|
}
|
|
151
150
|
|
|
152
151
|
/**
|
|
153
152
|
* The WebSocket URL of the runtime.
|
|
154
153
|
*/
|
|
155
154
|
public getWsURL(sessionId: SessionId): URL {
|
|
156
|
-
const
|
|
157
|
-
return this.
|
|
155
|
+
const url = this.getSessionURL("/ws", sessionId);
|
|
156
|
+
return asWsUrl(this.appendCrossOriginAuth(url).toString());
|
|
158
157
|
}
|
|
159
158
|
|
|
160
159
|
/**
|
|
@@ -164,11 +163,7 @@ export class RuntimeManager {
|
|
|
164
163
|
* (see `headers()`) instead of the URL.
|
|
165
164
|
*/
|
|
166
165
|
public getSseURL(sessionId: SessionId): URL {
|
|
167
|
-
const url = this.
|
|
168
|
-
path: "/sse",
|
|
169
|
-
searchParams: this.getSessionSearchParams(sessionId),
|
|
170
|
-
restrictToKnownQueryParams: false,
|
|
171
|
-
});
|
|
166
|
+
const url = this.getSessionURL("/sse", sessionId);
|
|
172
167
|
// The page URL can still carry access_token (merged in from the
|
|
173
168
|
// current page's params, before auth cleanup strips it); never
|
|
174
169
|
// forward it — a token in the URL leaks into proxy/server logs.
|
|
@@ -180,7 +175,8 @@ export class RuntimeManager {
|
|
|
180
175
|
* The WebSocket Sync URL of the runtime, for real-time updates.
|
|
181
176
|
*/
|
|
182
177
|
public getWsSyncURL(sessionId: SessionId): URL {
|
|
183
|
-
|
|
178
|
+
const url = this.getSessionURL("/ws_sync", sessionId);
|
|
179
|
+
return asWsUrl(this.appendCrossOriginAuth(url).toString());
|
|
184
180
|
}
|
|
185
181
|
|
|
186
182
|
/**
|
package/src/css/globals.css
CHANGED
|
@@ -92,6 +92,7 @@
|
|
|
92
92
|
--action-hover: var(--yellow-10, #ffdc00);
|
|
93
93
|
--action-border: var(--yellow-11, #9e6c00);
|
|
94
94
|
--action-foreground: var(--yellow-12, #473b1f);
|
|
95
|
+
--search-highlight: var(--yellow-4, #fff394);
|
|
95
96
|
--link: light-dark(hsl(211deg 90% 42%), hsl(211deg 90% 62%));
|
|
96
97
|
--link-visited: light-dark(hsl(272deg 51% 54%), hsl(272deg 51% 74%));
|
|
97
98
|
--stale: hsl(42deg 56% 44% / 25%);
|
|
@@ -140,6 +141,7 @@
|
|
|
140
141
|
--action-hover: var(--yellow-7, #665417);
|
|
141
142
|
--action-border: var(--yellow-11, #f5e147);
|
|
142
143
|
--action-foreground: var(--yellow-12, #f6eeb4);
|
|
144
|
+
--search-highlight: var(--yellow-6, #524202);
|
|
143
145
|
--stale: hsl(54deg 100% 86.7% / 25%);
|
|
144
146
|
}
|
|
145
147
|
}
|