@tutti-os/workspace-file-manager 0.0.10 → 0.0.12

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/index.js CHANGED
@@ -5,27 +5,31 @@ import {
5
5
  decodeWorkspaceTextFile,
6
6
  formatWorkspaceFileBytes,
7
7
  formatWorkspaceFileModifiedTime,
8
+ isWorkspaceApplicationBundle,
8
9
  isWorkspaceFileBrowserOpenable,
9
10
  isWorkspaceTextFileTooLarge,
10
11
  looksLikeBinaryText,
11
12
  normalizeWorkspaceFilePath,
12
13
  resolveWorkspaceFileActivationTarget,
14
+ resolveWorkspaceFileEntryIconCacheKey,
13
15
  resolveWorkspaceFileExtension,
14
16
  resolveWorkspaceFileVisualKind,
15
17
  resolveWorkspaceImageMimeType,
18
+ shouldResolveWorkspaceFileEntryIcon,
19
+ shouldUseWorkspaceFileExtensionDocumentIcon,
16
20
  splitWorkspaceFileName,
17
21
  workspaceFilePreviewMaxBytes,
18
22
  workspaceFileTextMaxBytes
19
- } from "./chunk-H3SDVOT7.js";
23
+ } from "./chunk-CSFEGRX7.js";
20
24
  import {
21
25
  createWorkspaceFileManagerI18nRuntime,
22
26
  resolveRevealInFolderLabel,
23
27
  workspaceFileManagerI18nNamespace,
24
28
  workspaceFileManagerI18nResources
25
- } from "./chunk-XOFPTWRV.js";
29
+ } from "./chunk-L6SAJEFE.js";
26
30
 
27
31
  // src/ui/WorkspaceFileManager.tsx
28
- import { useEffect as useEffect4, useMemo as useMemo2, useRef as useRef4, useState as useState7 } from "react";
32
+ import { useEffect as useEffect5, useMemo as useMemo2, useRef as useRef5, useState as useState7 } from "react";
29
33
  import { cn as cn6 } from "@tutti-os/ui-system";
30
34
 
31
35
  // src/ui/WorkspaceFileManagerMenus.tsx
@@ -906,15 +910,15 @@ import {
906
910
  import { WorkspaceFilePreviewSurface as SharedWorkspaceFilePreviewSurface } from "@tutti-os/workspace-file-preview/react";
907
911
  import {
908
912
  useCallback as useCallback2,
909
- useEffect as useEffect2,
913
+ useEffect as useEffect3,
910
914
  useLayoutEffect as useLayoutEffect3,
911
- useRef as useRef3,
915
+ useRef as useRef4,
912
916
  useState as useState2
913
917
  } from "react";
914
918
 
915
919
  // src/ui/WorkspaceFileManagerIconGrid.tsx
916
920
  import { cn as cn3 } from "@tutti-os/ui-system";
917
- import { useLayoutEffect as useLayoutEffect2, useRef as useRef2 } from "react";
921
+ import { useLayoutEffect as useLayoutEffect2, useRef as useRef3 } from "react";
918
922
 
919
923
  // src/ui/WorkspaceFileEntryIcon.tsx
920
924
  import {
@@ -926,46 +930,87 @@ import {
926
930
  VideoFileIcon,
927
931
  cn as cn2
928
932
  } from "@tutti-os/ui-system";
929
-
930
- // src/ui/workspaceFileEntryIconPolicy.ts
931
- function shouldResolveWorkspaceFileEntryIcon(entry) {
932
- if (isWorkspaceApplicationBundle(entry)) {
933
- return true;
934
- }
935
- if (shouldUseWorkspaceFileExtensionDocumentIcon(entry)) {
936
- return false;
937
- }
938
- return entry.kind === "file";
939
- }
940
- function shouldUseWorkspaceFileExtensionDocumentIcon(entry) {
941
- if (entry.kind !== "file") {
942
- return false;
943
- }
944
- const visualKind = resolveWorkspaceFileVisualKind(entry);
945
- return visualKind === "code" || visualKind === "markdown" || classifyWorkspaceFilePreviewKind(entry) === "text";
946
- }
947
- function isWorkspaceApplicationBundle(entry) {
948
- return entry.name.trim().toLowerCase().endsWith(".app");
949
- }
950
- function resolveWorkspaceFileEntryIconCacheKey(entry) {
951
- return `${entry.path}:${entry.mtimeMs ?? 0}`;
952
- }
953
-
954
- // src/ui/WorkspaceFileEntryIcon.tsx
933
+ import { useEffect as useEffect2, useRef as useRef2 } from "react";
955
934
  import { jsx as jsx2, jsxs as jsxs2 } from "react/jsx-runtime";
956
935
  function WorkspaceFileEntryIcon({
957
936
  entry,
958
937
  frameClassName,
959
938
  iconClassName = "size-4",
960
939
  iconUrlByCacheKey,
961
- isEnteringDirectory = false
940
+ isEnteringDirectory = false,
941
+ onViewportLeave,
942
+ onViewportEnter
962
943
  }) {
963
944
  const visualKind = resolveWorkspaceFileVisualKind(entry);
964
945
  const isAppBundle = isWorkspaceApplicationBundle(entry);
965
- const iconUrl = iconUrlByCacheKey?.get(resolveWorkspaceFileEntryIconCacheKey(entry)) ?? null;
946
+ const cacheKey = resolveWorkspaceFileEntryIconCacheKey(entry);
947
+ const iconUrl = iconUrlByCacheKey?.get(cacheKey) ?? null;
948
+ const frameRef = useRef2(null);
949
+ const visibleEntryRef = useRef2(null);
950
+ const visibleEntryCacheKeyRef = useRef2(null);
951
+ useEffect2(() => {
952
+ function reportViewportLeave() {
953
+ const visibleEntry = visibleEntryRef.current;
954
+ if (!visibleEntry) {
955
+ return;
956
+ }
957
+ visibleEntryRef.current = null;
958
+ visibleEntryCacheKeyRef.current = null;
959
+ onViewportLeave?.(visibleEntry);
960
+ }
961
+ function reportViewportEnter() {
962
+ if (visibleEntryCacheKeyRef.current === cacheKey) {
963
+ return;
964
+ }
965
+ reportViewportLeave();
966
+ visibleEntryRef.current = entry;
967
+ visibleEntryCacheKeyRef.current = cacheKey;
968
+ onViewportEnter?.(entry);
969
+ }
970
+ if (!onViewportEnter || isEnteringDirectory) {
971
+ reportViewportLeave();
972
+ return;
973
+ }
974
+ const element = frameRef.current;
975
+ if (!element || typeof IntersectionObserver === "undefined") {
976
+ reportViewportEnter();
977
+ return () => {
978
+ reportViewportLeave();
979
+ };
980
+ }
981
+ const viewportRoot = resolveWorkspaceFileEntryIconViewport(element);
982
+ const observer = new IntersectionObserver(
983
+ (records) => {
984
+ const intersecting = records.some(
985
+ (record) => record.isIntersecting && record.intersectionRatio > 0
986
+ );
987
+ if (intersecting) {
988
+ reportViewportEnter();
989
+ return;
990
+ }
991
+ reportViewportLeave();
992
+ },
993
+ { root: viewportRoot, rootMargin: "0px", threshold: 0 }
994
+ );
995
+ observer.observe(element);
996
+ const initiallyVisible = isWorkspaceFileEntryIconVisible(
997
+ element,
998
+ viewportRoot
999
+ );
1000
+ if (initiallyVisible) {
1001
+ reportViewportEnter();
1002
+ } else {
1003
+ reportViewportLeave();
1004
+ }
1005
+ return () => {
1006
+ observer.disconnect();
1007
+ reportViewportLeave();
1008
+ };
1009
+ }, [cacheKey, entry, isEnteringDirectory, onViewportLeave, onViewportEnter]);
966
1010
  return /* @__PURE__ */ jsx2(
967
1011
  "span",
968
1012
  {
1013
+ ref: frameRef,
969
1014
  className: cn2(
970
1015
  "grid flex-none place-items-center overflow-hidden",
971
1016
  frameClassName,
@@ -975,8 +1020,13 @@ function WorkspaceFileEntryIcon({
975
1020
  "img",
976
1021
  {
977
1022
  alt: "",
978
- className: cn2(iconClassName, "rounded-[4px] object-contain"),
1023
+ className: cn2(
1024
+ iconClassName,
1025
+ visualKind === "image" ? "rounded-[6px] border border-[var(--border-1)] bg-[var(--transparency-block)] object-contain shadow-sm" : "rounded-[4px] object-contain"
1026
+ ),
1027
+ decoding: "async",
979
1028
  draggable: false,
1029
+ loading: "lazy",
980
1030
  src: iconUrl
981
1031
  }
982
1032
  ) : /* @__PURE__ */ jsx2(
@@ -990,6 +1040,19 @@ function WorkspaceFileEntryIcon({
990
1040
  }
991
1041
  );
992
1042
  }
1043
+ function resolveWorkspaceFileEntryIconViewport(element) {
1044
+ return element.closest('[data-slot="scroll-area-viewport"]');
1045
+ }
1046
+ function isWorkspaceFileEntryIconVisible(element, viewportRoot) {
1047
+ const elementRect = element.getBoundingClientRect();
1048
+ const viewportRect = viewportRoot?.getBoundingClientRect() ?? {
1049
+ bottom: window.innerHeight,
1050
+ left: 0,
1051
+ right: window.innerWidth,
1052
+ top: 0
1053
+ };
1054
+ return elementRect.bottom > viewportRect.top && elementRect.top < viewportRect.bottom && elementRect.right > viewportRect.left && elementRect.left < viewportRect.right;
1055
+ }
993
1056
  function DefaultEntryIcon({
994
1057
  entry,
995
1058
  iconClassName,
@@ -1024,7 +1087,7 @@ function ExtensionDocumentIcon({
1024
1087
  iconClassName
1025
1088
  }) {
1026
1089
  const extension = resolveWorkspaceFileExtension(entry.name).slice(0, 5).toUpperCase();
1027
- const showExtension = extension.length > 0 && iconClassName.includes("52px");
1090
+ const showExtension = extension.length > 0 && !iconClassName.includes("size-4");
1028
1091
  return /* @__PURE__ */ jsxs2(
1029
1092
  "span",
1030
1093
  {
@@ -1046,19 +1109,19 @@ function entryIconColorClassName(visualKind, isAppBundle) {
1046
1109
  }
1047
1110
 
1048
1111
  // src/ui/workspaceFileManagerIconGridLayout.ts
1049
- var workspaceFileManagerIconGridIconSizePx = 52;
1050
- var workspaceFileManagerIconGridTileMinWidthPx = 108;
1051
- var workspaceFileManagerIconGridTileMaxWidthPx = 120;
1112
+ var workspaceFileManagerIconGridIconSizePx = 84;
1113
+ var workspaceFileManagerIconGridTileMinWidthPx = 136;
1114
+ var workspaceFileManagerIconGridTileMaxWidthPx = 148;
1052
1115
  var workspaceFileManagerIconGridLayout = {
1053
1116
  iconSizePx: workspaceFileManagerIconGridIconSizePx,
1054
1117
  tileMaxWidthPx: workspaceFileManagerIconGridTileMaxWidthPx,
1055
1118
  tileMinWidthPx: workspaceFileManagerIconGridTileMinWidthPx
1056
1119
  };
1057
1120
  function workspaceFileManagerIconGridIconClassName() {
1058
- return "size-[52px]";
1121
+ return "size-[84px]";
1059
1122
  }
1060
1123
  function workspaceFileManagerIconGridFrameClassName() {
1061
- return "size-[60px]";
1124
+ return "size-[92px]";
1062
1125
  }
1063
1126
 
1064
1127
  // src/ui/WorkspaceFileManagerIconGrid.tsx
@@ -1076,6 +1139,8 @@ function WorkspaceFileManagerIconGrid({
1076
1139
  moveDragPreviewEntryPath,
1077
1140
  moveDragTargetEntryPath,
1078
1141
  pendingDirectoryPath,
1142
+ onEntryIconViewportLeave,
1143
+ onEntryIconViewportEnter,
1079
1144
  selectedPath,
1080
1145
  onCancelInlineRename,
1081
1146
  onClearInlineRenameValidation,
@@ -1107,6 +1172,8 @@ function WorkspaceFileManagerIconGrid({
1107
1172
  moveDragActive,
1108
1173
  moveDragSource: moveDragPreviewEntryPath === entry.path,
1109
1174
  moveDragTarget: moveDragTargetEntryPath === entry.path,
1175
+ onEntryIconViewportLeave,
1176
+ onEntryIconViewportEnter,
1110
1177
  selected: selectedPath === entry.path,
1111
1178
  onCancelInlineRename,
1112
1179
  onClearInlineRenameValidation,
@@ -1134,6 +1201,8 @@ function WorkspaceFileManagerIconTile({
1134
1201
  moveDragActive,
1135
1202
  moveDragSource,
1136
1203
  moveDragTarget,
1204
+ onEntryIconViewportLeave,
1205
+ onEntryIconViewportEnter,
1137
1206
  selected,
1138
1207
  onCancelInlineRename,
1139
1208
  onClearInlineRenameValidation,
@@ -1143,7 +1212,7 @@ function WorkspaceFileManagerIconTile({
1143
1212
  onClick,
1144
1213
  onPointerDown
1145
1214
  }) {
1146
- const buttonRef = useRef2(null);
1215
+ const buttonRef = useRef3(null);
1147
1216
  const nameParts = splitWorkspaceFileName(entry.name);
1148
1217
  useLayoutEffect2(() => {
1149
1218
  if (!selected) {
@@ -1155,7 +1224,7 @@ function WorkspaceFileManagerIconTile({
1155
1224
  });
1156
1225
  }, [selected]);
1157
1226
  const tileClassName = cn3(
1158
- "flex min-w-0 max-w-[120px] flex-col items-center gap-1.5 rounded-md border border-transparent px-2 py-2 text-center transition-colors",
1227
+ "flex min-w-0 max-w-[148px] flex-col items-center gap-1.5 rounded-md border border-transparent px-2 py-2 text-center transition-colors",
1159
1228
  isInlineRenaming ? "cursor-default" : "cursor-pointer hover:bg-transparency-block",
1160
1229
  moveDragActive && "cursor-grabbing",
1161
1230
  selected || contextMenuActive || isInlineRenaming ? "border-[var(--border-focus)] bg-transparency-block text-[var(--text-primary)]" : "text-[var(--text-secondary)]",
@@ -1169,7 +1238,9 @@ function WorkspaceFileManagerIconTile({
1169
1238
  frameClassName: workspaceFileManagerIconGridFrameClassName(),
1170
1239
  iconClassName: workspaceFileManagerIconGridIconClassName(),
1171
1240
  iconUrlByCacheKey,
1172
- isEnteringDirectory
1241
+ isEnteringDirectory,
1242
+ onViewportLeave: onEntryIconViewportLeave,
1243
+ onViewportEnter: onEntryIconViewportEnter
1173
1244
  }
1174
1245
  );
1175
1246
  if (isInlineRenaming) {
@@ -1243,7 +1314,7 @@ function IconTileRenameInput({
1243
1314
  }) {
1244
1315
  const nameParts = splitWorkspaceFileName(entry.name);
1245
1316
  const hasFileExtension = nameParts.end.length > 0 && nameParts.end.startsWith(".");
1246
- const inputRef = useRef2(null);
1317
+ const inputRef = useRef3(null);
1247
1318
  useLayoutEffect2(() => {
1248
1319
  const input = inputRef.current;
1249
1320
  if (!input) {
@@ -1448,6 +1519,8 @@ function WorkspaceFileManagerPanels({
1448
1519
  layoutMode,
1449
1520
  pendingDirectoryPath,
1450
1521
  previewState,
1522
+ onEntryIconViewportLeave,
1523
+ onEntryIconViewportEnter,
1451
1524
  selectedEntry,
1452
1525
  selectedPath,
1453
1526
  state,
@@ -1465,18 +1538,18 @@ function WorkspaceFileManagerPanels({
1465
1538
  const { rootRef } = useWorkspaceFileManagerStackedLayout(
1466
1539
  workspaceFileManagerStackedBreakpoint
1467
1540
  );
1468
- const previewResizeRef = useRef3(null);
1541
+ const previewResizeRef = useRef4(null);
1469
1542
  const [previewPaneWidth, setPreviewPaneWidth] = useState2(
1470
1543
  workspaceFileManagerPreviewDefaultWidth
1471
1544
  );
1472
1545
  const [moveDragPreview, setMoveDragPreview] = useState2(null);
1473
- const lastEntryClickRef = useRef3(
1546
+ const lastEntryClickRef = useRef4(
1474
1547
  null
1475
1548
  );
1476
- const moveDragAutoScrollRef = useRef3(null);
1477
- const moveDragRef = useRef3(null);
1478
- const suppressNextClickRef = useRef3(false);
1479
- const entriesRef = useRef3(state.entries);
1549
+ const moveDragAutoScrollRef = useRef4(null);
1550
+ const moveDragRef = useRef4(null);
1551
+ const suppressNextClickRef = useRef4(false);
1552
+ const entriesRef = useRef4(state.entries);
1480
1553
  const nativeEntryDragEnabled = onEntryDragStart !== void 0 && (entryDragMode === "external" || entryDragMode === void 0 && !canMove);
1481
1554
  const internalMoveEnabled = canMove && !nativeEntryDragEnabled;
1482
1555
  const dateColumnLabel = resolveWorkspaceFileManagerDateColumnLabel(
@@ -1590,11 +1663,11 @@ function WorkspaceFileManagerPanels({
1590
1663
  },
1591
1664
  [entrySelectionEnabled, onOpenEntry, onSelect, shouldSuppressEntryClick]
1592
1665
  );
1593
- useEffect2(() => {
1666
+ useEffect3(() => {
1594
1667
  lastEntryClickRef.current = null;
1595
1668
  entriesRef.current = state.entries;
1596
1669
  }, [state.entries]);
1597
- useEffect2(() => {
1670
+ useEffect3(() => {
1598
1671
  function handleDocumentPointerMove(event) {
1599
1672
  const drag = moveDragRef.current;
1600
1673
  if (!drag) {
@@ -1783,6 +1856,8 @@ function WorkspaceFileManagerPanels({
1783
1856
  moveDragPreviewEntryPath: moveDragPreview?.entry.path ?? null,
1784
1857
  moveDragTargetEntryPath: moveDragPreview?.targetDirectoryPath ?? null,
1785
1858
  pendingDirectoryPath,
1859
+ onEntryIconViewportLeave,
1860
+ onEntryIconViewportEnter,
1786
1861
  selectedPath,
1787
1862
  onCancelInlineRename,
1788
1863
  onClearInlineRenameValidation,
@@ -1842,6 +1917,8 @@ function WorkspaceFileManagerPanels({
1842
1917
  moveDragTarget: moveDragPreview?.targetDirectoryPath === entry.path,
1843
1918
  tableCellPaddingClassName,
1844
1919
  selected: selectedPath === entry.path,
1920
+ onEntryIconViewportLeave,
1921
+ onEntryIconViewportEnter,
1845
1922
  onCancelInlineRename,
1846
1923
  onClearInlineRenameValidation,
1847
1924
  onConfirmInlineRename,
@@ -1924,7 +2001,9 @@ function WorkspaceFileManagerPanels({
1924
2001
  MoveDragPreview,
1925
2002
  {
1926
2003
  iconUrlByCacheKey,
1927
- preview: moveDragPreview
2004
+ preview: moveDragPreview,
2005
+ onEntryIconViewportLeave,
2006
+ onEntryIconViewportEnter
1928
2007
  }
1929
2008
  ) : null
1930
2009
  ]
@@ -1932,7 +2011,7 @@ function WorkspaceFileManagerPanels({
1932
2011
  );
1933
2012
  }
1934
2013
  function useWorkspaceFileManagerStackedLayout(breakpoint) {
1935
- const rootRef = useRef3(null);
2014
+ const rootRef = useRef4(null);
1936
2015
  const [isStacked, setIsStacked] = useState2(false);
1937
2016
  useLayoutEffect3(() => {
1938
2017
  const element = rootRef.current;
@@ -1991,6 +2070,8 @@ function EntryRow({
1991
2070
  moveDragActive,
1992
2071
  moveDragSource,
1993
2072
  moveDragTarget,
2073
+ onEntryIconViewportLeave,
2074
+ onEntryIconViewportEnter,
1994
2075
  selected,
1995
2076
  tableCellPaddingClassName,
1996
2077
  onCancelInlineRename,
@@ -2001,8 +2082,8 @@ function EntryRow({
2001
2082
  onClick,
2002
2083
  onPointerDown
2003
2084
  }) {
2004
- const buttonRowRef = useRef3(null);
2005
- const divRowRef = useRef3(null);
2085
+ const buttonRowRef = useRef4(null);
2086
+ const divRowRef = useRef4(null);
2006
2087
  useLayoutEffect3(() => {
2007
2088
  if (!selected) {
2008
2089
  return;
@@ -2042,6 +2123,8 @@ function EntryRow({
2042
2123
  isEnteringDirectory,
2043
2124
  isInlineRenaming,
2044
2125
  isRenaming,
2126
+ onEntryIconViewportLeave,
2127
+ onEntryIconViewportEnter,
2045
2128
  onCancelInlineRename,
2046
2129
  onClearInlineRenameValidation,
2047
2130
  onConfirmInlineRename
@@ -2106,7 +2189,9 @@ function EntryRow({
2106
2189
  }
2107
2190
  function MoveDragPreview({
2108
2191
  iconUrlByCacheKey,
2109
- preview
2192
+ preview,
2193
+ onEntryIconViewportLeave,
2194
+ onEntryIconViewportEnter
2110
2195
  }) {
2111
2196
  return /* @__PURE__ */ jsxs4(
2112
2197
  "div",
@@ -2123,9 +2208,11 @@ function MoveDragPreview({
2123
2208
  WorkspaceFileEntryIcon,
2124
2209
  {
2125
2210
  entry: preview.entry,
2126
- frameClassName: "size-[18px]",
2127
- iconClassName: "size-4",
2128
- iconUrlByCacheKey
2211
+ frameClassName: "size-7",
2212
+ iconClassName: "size-6",
2213
+ iconUrlByCacheKey,
2214
+ onViewportLeave: onEntryIconViewportLeave,
2215
+ onViewportEnter: onEntryIconViewportEnter
2129
2216
  }
2130
2217
  ),
2131
2218
  /* @__PURE__ */ jsx4("span", { className: "min-w-0 truncate", children: preview.entry.name })
@@ -2176,6 +2263,8 @@ function EntryNameCell({
2176
2263
  isEnteringDirectory = false,
2177
2264
  isInlineRenaming = false,
2178
2265
  isRenaming = false,
2266
+ onEntryIconViewportLeave,
2267
+ onEntryIconViewportEnter,
2179
2268
  onCancelInlineRename,
2180
2269
  onClearInlineRenameValidation,
2181
2270
  onConfirmInlineRename
@@ -2183,9 +2272,9 @@ function EntryNameCell({
2183
2272
  const nameParts = splitWorkspaceFileName(entry.name);
2184
2273
  const hasFileExtension = nameParts.end.length > 0 && nameParts.end.startsWith(".");
2185
2274
  const [name, setName] = useState2(entry.name);
2186
- const inputRef = useRef3(null);
2187
- const skipBlurRef = useRef3(false);
2188
- useEffect2(() => {
2275
+ const inputRef = useRef4(null);
2276
+ const skipBlurRef = useRef4(false);
2277
+ useEffect3(() => {
2189
2278
  if (!isInlineRenaming) {
2190
2279
  return;
2191
2280
  }
@@ -2220,9 +2309,11 @@ function EntryNameCell({
2220
2309
  WorkspaceFileEntryIcon,
2221
2310
  {
2222
2311
  entry,
2223
- frameClassName: "size-[18px]",
2224
- iconClassName: "size-4",
2225
- iconUrlByCacheKey
2312
+ frameClassName: "size-7",
2313
+ iconClassName: "size-6",
2314
+ iconUrlByCacheKey,
2315
+ onViewportLeave: onEntryIconViewportLeave,
2316
+ onViewportEnter: onEntryIconViewportEnter
2226
2317
  }
2227
2318
  ),
2228
2319
  /* @__PURE__ */ jsxs4("span", { className: "flex min-w-0 flex-1 flex-col gap-0.5", children: [
@@ -2282,10 +2373,12 @@ function EntryNameCell({
2282
2373
  WorkspaceFileEntryIcon,
2283
2374
  {
2284
2375
  entry,
2285
- frameClassName: "size-[18px]",
2286
- iconClassName: "size-4",
2376
+ frameClassName: "size-7",
2377
+ iconClassName: "size-6",
2287
2378
  iconUrlByCacheKey,
2288
- isEnteringDirectory
2379
+ isEnteringDirectory,
2380
+ onViewportLeave: onEntryIconViewportLeave,
2381
+ onViewportEnter: onEntryIconViewportEnter
2289
2382
  }
2290
2383
  ),
2291
2384
  /* @__PURE__ */ jsxs4("span", { className: "flex min-w-0 max-w-full overflow-hidden whitespace-nowrap text-sm", children: [
@@ -2421,6 +2514,10 @@ import {
2421
2514
  DropdownMenuTrigger,
2422
2515
  LoadingIcon as LoadingIcon3,
2423
2516
  RefreshIcon,
2517
+ Tooltip,
2518
+ TooltipContent,
2519
+ TooltipProvider,
2520
+ TooltipTrigger,
2424
2521
  ViewGridLinedIcon,
2425
2522
  ViewListLinedIcon,
2426
2523
  cn as cn5
@@ -2544,7 +2641,7 @@ function LayoutModeToggle({
2544
2641
  { label: copy.t("sizeLabel"), mode: "size" },
2545
2642
  { label: copy.t("arrangeTagsLabel"), mode: "tags" }
2546
2643
  ];
2547
- return /* @__PURE__ */ jsxs5(
2644
+ return /* @__PURE__ */ jsx5(TooltipProvider, { delayDuration: 300, children: /* @__PURE__ */ jsxs5(
2548
2645
  "div",
2549
2646
  {
2550
2647
  className: "flex items-center rounded-md border border-[var(--border-1)] bg-[var(--transparency-block)] p-0.5",
@@ -2555,6 +2652,7 @@ function LayoutModeToggle({
2555
2652
  {
2556
2653
  active: layoutMode === "icon",
2557
2654
  ariaLabel: copy.t("layoutIconViewLabel"),
2655
+ tooltipLabel: copy.t("layoutIconViewTooltipLabel"),
2558
2656
  onClick: () => {
2559
2657
  onLayoutModeChange("icon");
2560
2658
  },
@@ -2566,6 +2664,7 @@ function LayoutModeToggle({
2566
2664
  {
2567
2665
  active: layoutMode === "list",
2568
2666
  ariaLabel: copy.t("layoutListViewLabel"),
2667
+ tooltipLabel: copy.t("layoutListViewTooltipLabel"),
2569
2668
  onClick: () => {
2570
2669
  onLayoutModeChange("list");
2571
2670
  },
@@ -2580,7 +2679,6 @@ function LayoutModeToggle({
2580
2679
  "aria-label": copy.t("arrangeMenuLabel"),
2581
2680
  className: "size-6 min-w-6 rounded-[4px] p-0 text-[var(--text-secondary)] data-[state=open]:bg-[var(--background-fronted)] data-[state=open]:text-[var(--text-primary)] data-[state=open]:shadow-sm",
2582
2681
  size: "icon-sm",
2583
- title: copy.t("arrangeMenuLabel"),
2584
2682
  type: "button",
2585
2683
  variant: "ghost",
2586
2684
  children: /* @__PURE__ */ jsx5(ChevronDownIcon, { className: "size-3.5" })
@@ -2597,7 +2695,9 @@ function LayoutModeToggle({
2597
2695
  {
2598
2696
  value: arrangeMode,
2599
2697
  onValueChange: (nextMode) => {
2600
- onArrangeModeChange(nextMode);
2698
+ onArrangeModeChange(
2699
+ nextMode
2700
+ );
2601
2701
  },
2602
2702
  children: arrangeOptions.map((option, index) => /* @__PURE__ */ jsxs5("div", { children: [
2603
2703
  index === 1 ? /* @__PURE__ */ jsx5(DropdownMenuSeparator, {}) : null,
@@ -2617,31 +2717,34 @@ function LayoutModeToggle({
2617
2717
  ] })
2618
2718
  ]
2619
2719
  }
2620
- );
2720
+ ) });
2621
2721
  }
2622
2722
  function LayoutModeButton({
2623
2723
  active,
2624
2724
  ariaLabel,
2625
2725
  children,
2626
- onClick
2726
+ onClick,
2727
+ tooltipLabel
2627
2728
  }) {
2628
- return /* @__PURE__ */ jsx5(
2629
- Button2,
2630
- {
2631
- "aria-label": ariaLabel,
2632
- "aria-pressed": active,
2633
- className: cn5(
2634
- "size-6 min-w-6 rounded-[4px] p-0 text-text-secondary",
2635
- active && "!bg-background-fronted text-foreground hover:!bg-background-fronted"
2636
- ),
2637
- size: "icon-sm",
2638
- title: ariaLabel,
2639
- type: "button",
2640
- variant: "ghost",
2641
- onClick,
2642
- children
2643
- }
2644
- );
2729
+ return /* @__PURE__ */ jsxs5(Tooltip, { children: [
2730
+ /* @__PURE__ */ jsx5(TooltipTrigger, { asChild: true, children: /* @__PURE__ */ jsx5(
2731
+ Button2,
2732
+ {
2733
+ "aria-label": ariaLabel,
2734
+ "aria-pressed": active,
2735
+ className: cn5(
2736
+ "size-6 min-w-6 rounded-[4px] p-0 text-text-secondary",
2737
+ active && "!bg-background-fronted text-foreground hover:!bg-background-fronted"
2738
+ ),
2739
+ size: "icon-sm",
2740
+ type: "button",
2741
+ variant: "ghost",
2742
+ onClick,
2743
+ children
2744
+ }
2745
+ ) }),
2746
+ /* @__PURE__ */ jsx5(TooltipContent, { side: "bottom", children: tooltipLabel })
2747
+ ] });
2645
2748
  }
2646
2749
  function ToolbarIconButton({
2647
2750
  ariaLabel,
@@ -2762,74 +2865,228 @@ function useWorkspaceFileManagerLayoutMode() {
2762
2865
  }
2763
2866
 
2764
2867
  // src/ui/useWorkspaceFileEntryIconUrls.ts
2765
- import { useEffect as useEffect3, useMemo, useState as useState6 } from "react";
2766
- function buildIconTargetSignature(entries) {
2767
- return entries.filter(shouldResolveWorkspaceFileEntryIcon).map((entry) => resolveWorkspaceFileEntryIconCacheKey(entry)).join("\0");
2768
- }
2769
- function useWorkspaceFileEntryIconUrls(input) {
2770
- const { entries, resolveEntryIconUrl } = input;
2771
- const [iconUrlByCacheKey, setIconUrlByCacheKey] = useState6(() => /* @__PURE__ */ new Map());
2772
- const iconTargetSignature = useMemo(
2773
- () => buildIconTargetSignature(entries),
2774
- [entries]
2775
- );
2776
- useEffect3(() => {
2777
- if (!resolveEntryIconUrl) {
2778
- setIconUrlByCacheKey(
2779
- (current) => current.size === 0 ? current : /* @__PURE__ */ new Map()
2780
- );
2868
+ import { useCallback as useCallback5, useEffect as useEffect4, useMemo, useState as useState6 } from "react";
2869
+
2870
+ // src/ui/workspaceFileEntryIconUrlQueue.ts
2871
+ var defaultMaxConcurrentIconRequests = 3;
2872
+ var imageThumbnailCacheKeyPrefix = "image-thumbnail:";
2873
+ function shouldKeepResolvedIconAfterViewportLeave(cacheKey) {
2874
+ return cacheKey.startsWith(imageThumbnailCacheKeyPrefix);
2875
+ }
2876
+ function createWorkspaceFileEntryIconUrlQueue(options) {
2877
+ const maxConcurrent = Math.max(
2878
+ 1,
2879
+ Math.floor(options.maxConcurrent ?? defaultMaxConcurrentIconRequests)
2880
+ );
2881
+ const iconUrlByCacheKey = /* @__PURE__ */ new Map();
2882
+ const inFlightCacheKeys = /* @__PURE__ */ new Set();
2883
+ const listeners = /* @__PURE__ */ new Set();
2884
+ const queuedEntries = /* @__PURE__ */ new Map();
2885
+ const visibleReferenceCountByCacheKey = /* @__PURE__ */ new Map();
2886
+ let activeCount = 0;
2887
+ let disposed = false;
2888
+ let retainedCacheKeys = null;
2889
+ const policyOptions = {
2890
+ includeImageThumbnails: options.includeImageThumbnails
2891
+ };
2892
+ function publish() {
2893
+ for (const listener of listeners) {
2894
+ listener();
2895
+ }
2896
+ }
2897
+ function isRetained(cacheKey) {
2898
+ return retainedCacheKeys === null || retainedCacheKeys.has(cacheKey);
2899
+ }
2900
+ function isVisible(cacheKey) {
2901
+ return (visibleReferenceCountByCacheKey.get(cacheKey) ?? 0) > 0;
2902
+ }
2903
+ function shouldResolveCacheKey(cacheKey) {
2904
+ return isVisible(cacheKey) || shouldKeepResolvedIconAfterViewportLeave(cacheKey);
2905
+ }
2906
+ function retainVisibleReference(cacheKey) {
2907
+ visibleReferenceCountByCacheKey.set(
2908
+ cacheKey,
2909
+ (visibleReferenceCountByCacheKey.get(cacheKey) ?? 0) + 1
2910
+ );
2911
+ }
2912
+ function releaseVisibleReference(cacheKey) {
2913
+ const nextCount = (visibleReferenceCountByCacheKey.get(cacheKey) ?? 0) - 1;
2914
+ if (nextCount > 0) {
2915
+ visibleReferenceCountByCacheKey.set(cacheKey, nextCount);
2781
2916
  return;
2782
2917
  }
2783
- const targets = entries.filter(shouldResolveWorkspaceFileEntryIcon);
2784
- if (targets.length === 0) {
2785
- setIconUrlByCacheKey(
2786
- (current) => current.size === 0 ? current : /* @__PURE__ */ new Map()
2787
- );
2918
+ visibleReferenceCountByCacheKey.delete(cacheKey);
2919
+ if (shouldKeepResolvedIconAfterViewportLeave(cacheKey)) {
2788
2920
  return;
2789
2921
  }
2790
- let cancelled = false;
2791
- void Promise.all(
2792
- targets.map(async (entry) => {
2793
- const cacheKey = resolveWorkspaceFileEntryIconCacheKey(entry);
2794
- try {
2795
- const iconUrl = await resolveEntryIconUrl(entry);
2796
- return [cacheKey, iconUrl?.trim() || null];
2797
- } catch {
2798
- return [cacheKey, null];
2922
+ queuedEntries.delete(cacheKey);
2923
+ const hadCachedIcon = iconUrlByCacheKey.delete(cacheKey);
2924
+ if (hadCachedIcon) {
2925
+ publish();
2926
+ }
2927
+ }
2928
+ function drain() {
2929
+ if (disposed || !options.resolveEntryIconUrl) {
2930
+ return;
2931
+ }
2932
+ while (activeCount < maxConcurrent && queuedEntries.size > 0) {
2933
+ const next = queuedEntries.entries().next().value;
2934
+ if (!next) {
2935
+ return;
2936
+ }
2937
+ const [cacheKey, entry] = next;
2938
+ queuedEntries.delete(cacheKey);
2939
+ if (!shouldResolveCacheKey(cacheKey) || iconUrlByCacheKey.has(cacheKey) || inFlightCacheKeys.has(cacheKey)) {
2940
+ continue;
2941
+ }
2942
+ activeCount += 1;
2943
+ inFlightCacheKeys.add(cacheKey);
2944
+ void options.resolveEntryIconUrl(entry).then((iconUrl) => {
2945
+ const shouldStore = !disposed && isRetained(cacheKey) && shouldResolveCacheKey(cacheKey);
2946
+ if (shouldStore) {
2947
+ iconUrlByCacheKey.set(cacheKey, iconUrl?.trim() || null);
2948
+ publish();
2799
2949
  }
2800
- })
2801
- ).then((results) => {
2802
- if (cancelled) {
2950
+ }).catch(() => {
2951
+ const shouldStore = !disposed && isRetained(cacheKey) && shouldResolveCacheKey(cacheKey);
2952
+ if (shouldStore) {
2953
+ iconUrlByCacheKey.set(cacheKey, null);
2954
+ publish();
2955
+ }
2956
+ }).finally(() => {
2957
+ activeCount -= 1;
2958
+ inFlightCacheKeys.delete(cacheKey);
2959
+ drain();
2960
+ });
2961
+ }
2962
+ }
2963
+ return {
2964
+ activate() {
2965
+ disposed = false;
2966
+ },
2967
+ dispose() {
2968
+ disposed = true;
2969
+ queuedEntries.clear();
2970
+ visibleReferenceCountByCacheKey.clear();
2971
+ listeners.clear();
2972
+ },
2973
+ leaveViewport(entry) {
2974
+ if (disposed || !shouldResolveWorkspaceFileEntryIcon(entry, policyOptions)) {
2803
2975
  return;
2804
2976
  }
2805
- setIconUrlByCacheKey((current) => {
2806
- const nextCacheKeys = new Set(results.map(([cacheKey]) => cacheKey));
2807
- let changed = false;
2808
- const next = /* @__PURE__ */ new Map();
2809
- for (const [cacheKey, iconUrl] of results) {
2810
- next.set(cacheKey, iconUrl);
2811
- if (current.get(cacheKey) !== iconUrl) {
2812
- changed = true;
2813
- }
2977
+ const cacheKey = resolveWorkspaceFileEntryIconCacheKey(entry);
2978
+ releaseVisibleReference(cacheKey);
2979
+ },
2980
+ enterViewport(entry) {
2981
+ if (disposed || !options.resolveEntryIconUrl || !shouldResolveWorkspaceFileEntryIcon(entry, policyOptions)) {
2982
+ return;
2983
+ }
2984
+ const cacheKey = resolveWorkspaceFileEntryIconCacheKey(entry);
2985
+ retainVisibleReference(cacheKey);
2986
+ if (iconUrlByCacheKey.has(cacheKey) || inFlightCacheKeys.has(cacheKey) || queuedEntries.has(cacheKey)) {
2987
+ return;
2988
+ }
2989
+ queuedEntries.set(cacheKey, entry);
2990
+ drain();
2991
+ },
2992
+ retainEntries(entries) {
2993
+ retainedCacheKeys = new Set(
2994
+ entries.filter(
2995
+ (entry) => shouldResolveWorkspaceFileEntryIcon(entry, policyOptions)
2996
+ ).map((entry) => resolveWorkspaceFileEntryIconCacheKey(entry))
2997
+ );
2998
+ let changed = false;
2999
+ for (const cacheKey of iconUrlByCacheKey.keys()) {
3000
+ if (!isRetained(cacheKey)) {
3001
+ iconUrlByCacheKey.delete(cacheKey);
3002
+ changed = true;
3003
+ continue;
2814
3004
  }
2815
- if (current.size !== next.size) {
3005
+ if (iconUrlByCacheKey.get(cacheKey) === null) {
3006
+ iconUrlByCacheKey.delete(cacheKey);
2816
3007
  changed = true;
2817
- } else if (!changed) {
2818
- for (const cacheKey of current.keys()) {
2819
- if (!nextCacheKeys.has(cacheKey)) {
2820
- changed = true;
2821
- break;
2822
- }
2823
- }
2824
3008
  }
2825
- return changed ? next : current;
2826
- });
2827
- });
3009
+ }
3010
+ for (const cacheKey of queuedEntries.keys()) {
3011
+ if (!isRetained(cacheKey)) {
3012
+ queuedEntries.delete(cacheKey);
3013
+ }
3014
+ }
3015
+ for (const cacheKey of visibleReferenceCountByCacheKey.keys()) {
3016
+ if (!isRetained(cacheKey)) {
3017
+ visibleReferenceCountByCacheKey.delete(cacheKey);
3018
+ }
3019
+ }
3020
+ if (changed) {
3021
+ publish();
3022
+ }
3023
+ },
3024
+ snapshot() {
3025
+ return new Map(iconUrlByCacheKey);
3026
+ },
3027
+ subscribe(listener) {
3028
+ if (disposed) {
3029
+ return () => {
3030
+ };
3031
+ }
3032
+ listeners.add(listener);
3033
+ return () => {
3034
+ listeners.delete(listener);
3035
+ };
3036
+ }
3037
+ };
3038
+ }
3039
+
3040
+ // src/ui/useWorkspaceFileEntryIconUrls.ts
3041
+ function buildIconTargetSignature(entries, options) {
3042
+ return entries.filter((entry) => shouldResolveWorkspaceFileEntryIcon(entry, options)).map((entry) => resolveWorkspaceFileEntryIconCacheKey(entry)).join("\0");
3043
+ }
3044
+ function useWorkspaceFileEntryIconUrls(input) {
3045
+ const { entries, includeImageThumbnails, resolveEntryIconUrl } = input;
3046
+ const queue = useMemo(
3047
+ () => createWorkspaceFileEntryIconUrlQueue({
3048
+ includeImageThumbnails,
3049
+ resolveEntryIconUrl
3050
+ }),
3051
+ [includeImageThumbnails, resolveEntryIconUrl]
3052
+ );
3053
+ const [iconUrlByCacheKey, setIconUrlByCacheKey] = useState6(() => queue.snapshot());
3054
+ const iconTargetSignature = useMemo(
3055
+ () => buildIconTargetSignature(entries, { includeImageThumbnails }),
3056
+ [entries, includeImageThumbnails]
3057
+ );
3058
+ useEffect4(() => {
3059
+ queue.activate();
2828
3060
  return () => {
2829
- cancelled = true;
3061
+ queue.dispose();
2830
3062
  };
2831
- }, [entries, iconTargetSignature, resolveEntryIconUrl]);
2832
- return iconUrlByCacheKey;
3063
+ }, [queue]);
3064
+ useEffect4(() => {
3065
+ queue.retainEntries(entries);
3066
+ }, [entries, iconTargetSignature, queue]);
3067
+ useEffect4(() => {
3068
+ setIconUrlByCacheKey(queue.snapshot());
3069
+ return queue.subscribe(() => {
3070
+ setIconUrlByCacheKey(queue.snapshot());
3071
+ });
3072
+ }, [queue]);
3073
+ const reportEntryIconViewportEnter = useCallback5(
3074
+ (entry) => {
3075
+ queue.enterViewport(entry);
3076
+ },
3077
+ [queue]
3078
+ );
3079
+ const reportEntryIconViewportLeave = useCallback5(
3080
+ (entry) => {
3081
+ queue.leaveViewport(entry);
3082
+ },
3083
+ [queue]
3084
+ );
3085
+ return {
3086
+ iconUrlByCacheKey,
3087
+ reportEntryIconViewportEnter,
3088
+ reportEntryIconViewportLeave
3089
+ };
2833
3090
  }
2834
3091
 
2835
3092
  // src/ui/workspaceFileManagerAnalytics.ts
@@ -3016,12 +3273,12 @@ function WorkspaceFileManager({
3016
3273
  session,
3017
3274
  surface = "card"
3018
3275
  }) {
3019
- const rootRef = useRef4(null);
3276
+ const rootRef = useRef5(null);
3020
3277
  const { arrangeMode, setArrangeMode } = useWorkspaceFileManagerArrangeMode();
3021
3278
  const { layoutMode, setLayoutMode } = useWorkspaceFileManagerLayoutMode();
3022
3279
  const rootView = useWorkspaceFileManagerRootView(session);
3023
3280
  const { state: panelsState, view: panelsView } = useWorkspaceFileManagerPanelsView(session);
3024
- useEffect4(() => {
3281
+ useEffect5(() => {
3025
3282
  function handleCopyShortcut(event) {
3026
3283
  if (!(event.metaKey || event.ctrlKey) || event.key !== "c" || event.shiftKey) {
3027
3284
  return;
@@ -3050,7 +3307,7 @@ function WorkspaceFileManager({
3050
3307
  window.removeEventListener("keydown", handleCopyShortcut);
3051
3308
  };
3052
3309
  }, [onCopyEntry, panelsState, panelsView.selectedEntry, session]);
3053
- useEffect4(() => {
3310
+ useEffect5(() => {
3054
3311
  function handleRenameShortcut(event) {
3055
3312
  if (event.key !== "Enter" || event.metaKey || event.ctrlKey || event.altKey) {
3056
3313
  return;
@@ -3076,7 +3333,7 @@ function WorkspaceFileManager({
3076
3333
  window.removeEventListener("keydown", handleRenameShortcut);
3077
3334
  };
3078
3335
  }, [panelsState, panelsView.selectedEntry, session]);
3079
- useEffect4(() => {
3336
+ useEffect5(() => {
3080
3337
  function resetDropOverlay() {
3081
3338
  session.resetDragDepth();
3082
3339
  }
@@ -3293,8 +3550,13 @@ function WorkspaceFileManagerPanelsContainer({
3293
3550
  () => sortWorkspaceFileEntriesForArrangeMode(state.entries, arrangeMode),
3294
3551
  [arrangeMode, state.entries]
3295
3552
  );
3296
- const iconUrlByCacheKey = useWorkspaceFileEntryIconUrls({
3553
+ const {
3554
+ iconUrlByCacheKey,
3555
+ reportEntryIconViewportEnter,
3556
+ reportEntryIconViewportLeave
3557
+ } = useWorkspaceFileEntryIconUrls({
3297
3558
  entries: arrangedEntries,
3559
+ includeImageThumbnails: true,
3298
3560
  resolveEntryIconUrl
3299
3561
  });
3300
3562
  return /* @__PURE__ */ jsx6(
@@ -3313,6 +3575,8 @@ function WorkspaceFileManagerPanelsContainer({
3313
3575
  layoutMode,
3314
3576
  pendingDirectoryPath: view.pendingDirectoryPath,
3315
3577
  previewState: view.previewState,
3578
+ onEntryIconViewportEnter: reportEntryIconViewportEnter,
3579
+ onEntryIconViewportLeave: reportEntryIconViewportLeave,
3316
3580
  selectedEntry: view.selectedEntry,
3317
3581
  selectedPath: view.selectedPath,
3318
3582
  showDropOverlay: view.showDropOverlay,
@@ -3426,11 +3690,11 @@ function WorkspaceFileManagerContextMenuContainer({
3426
3690
  resolveOpenWithApplicationIcon,
3427
3691
  session
3428
3692
  }) {
3429
- const contextMenuRef = useRef4(null);
3693
+ const contextMenuRef = useRef5(null);
3430
3694
  const { view } = useWorkspaceFileManagerContextMenuView(session);
3431
3695
  const [openWithApplications, setOpenWithApplications] = useState7([]);
3432
3696
  const [openWithLoading, setOpenWithLoading] = useState7(false);
3433
- useEffect4(() => {
3697
+ useEffect5(() => {
3434
3698
  const entry = view.contextMenu?.entry;
3435
3699
  if (!entry || !view.showOpenWithAction) {
3436
3700
  return;
@@ -3592,7 +3856,7 @@ function WorkspaceFileManagerContextMenuContainer({
3592
3856
  }
3593
3857
  function useCloseContextMenuOnOutsideInteraction(input) {
3594
3858
  const { contextMenuRef, isOpen, session } = input;
3595
- useEffect4(() => {
3859
+ useEffect5(() => {
3596
3860
  if (!isOpen) {
3597
3861
  return;
3598
3862
  }