mirror of
https://github.com/nimbold/Firelink.git
synced 2026-07-26 12:08:27 +00:00
fix(ui): harden table drag and keychain startup
This commit is contained in:
@@ -2,6 +2,7 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
@@ -41,6 +42,14 @@ function runNpmScript(script) {
|
||||
run('npm', ['run', script]);
|
||||
}
|
||||
|
||||
// A packaged Tauri artifact must have its own consent identity. A source or
|
||||
// commit fingerprint cannot distinguish two fresh signed/package builds made
|
||||
// from the same checkout, which would let an updated binary silently reuse the
|
||||
// previous binary's credential-store approval. Keep any configured identity
|
||||
// as useful provenance, but always add the artifact nonce.
|
||||
const configuredBuildId = process.env.VITE_BUILD_ID?.trim();
|
||||
process.env.VITE_BUILD_ID = `${configuredBuildId || 'artifact'}-${randomUUID()}`;
|
||||
|
||||
run(process.execPath, ['scripts/stage-engines.js']);
|
||||
run(process.execPath, ['scripts/verify-binaries.js', '--staged']);
|
||||
|
||||
|
||||
+7
-7
@@ -3,6 +3,7 @@ import { schedulerCompletionState } from './utils/schedulerCompletion';
|
||||
import { lazy, Suspense, useCallback, useEffect, useRef, useState } from "react";
|
||||
import { Sidebar, SidebarFilter } from "./components/Sidebar";
|
||||
import { DownloadTable } from "./components/DownloadTable";
|
||||
import { KeychainPermissionModal } from './components/KeychainPermissionModal';
|
||||
import { extractValidDownloadUrls } from './utils/url';
|
||||
import { readClipboardDownloadUrls } from './utils/clipboard';
|
||||
import { listenEvent as listen, invokeCommand as invoke } from "./ipc";
|
||||
@@ -52,10 +53,6 @@ const PropertiesModal = lazy(() => import('./components/PropertiesModal').then(m
|
||||
const DeleteConfirmationModal = lazy(() => import('./components/DeleteConfirmationModal').then(module => ({
|
||||
default: module.DeleteConfirmationModal,
|
||||
})));
|
||||
const KeychainPermissionModal = lazy(() => import('./components/KeychainPermissionModal').then(module => ({
|
||||
default: module.KeychainPermissionModal,
|
||||
})));
|
||||
|
||||
const preloadPageChunks = async () => {
|
||||
for (const load of pageChunkLoaders) {
|
||||
try {
|
||||
@@ -500,14 +497,17 @@ function App() {
|
||||
let changed = false;
|
||||
if (deferKeychainHydration) {
|
||||
settings.setKeychainAccessReady(false);
|
||||
if (showKeychainPrompt) {
|
||||
// Commit the explanation before the harmless session-token IPC so
|
||||
// a slow startup cannot leave the user facing an unexplained
|
||||
// credential-store request.
|
||||
settings.setShowKeychainModal(true);
|
||||
}
|
||||
// This token is already owned by the backend and does not access
|
||||
// the OS credential store. Render our explanation before any native
|
||||
// Keychain/Credential Manager prompt can be user-triggered.
|
||||
await settings.hydrateSessionPairingToken(isStartupActive);
|
||||
if (!active) return;
|
||||
if (showKeychainPrompt) {
|
||||
settings.setShowKeychainModal(true);
|
||||
}
|
||||
} else {
|
||||
// The backend keeps credential-store access disabled for every new
|
||||
// process. Arm it only after the persisted startup decision has
|
||||
|
||||
@@ -98,13 +98,10 @@ export const DownloadItem = React.memo<DownloadItemProps>(({
|
||||
gridColumn: getColumnGridColumn(key, columnOrder),
|
||||
} as React.CSSProperties);
|
||||
|
||||
const trailingColumnClass = (key: DownloadTableColumnKey) =>
|
||||
key === columnOrder[columnOrder.length - 1] ? 'download-column-cell--trailing' : '';
|
||||
|
||||
const cells: Record<DownloadTableColumnKey, React.ReactNode> = {
|
||||
'File Name': (
|
||||
<div
|
||||
className={`download-column-cell download-file-cell download-column-file-name ${trailingColumnClass('File Name')}`}
|
||||
className="download-column-cell download-file-cell download-column-file-name"
|
||||
style={columnStyle('File Name')}
|
||||
>
|
||||
<div className="download-cell-content">
|
||||
@@ -119,7 +116,7 @@ export const DownloadItem = React.memo<DownloadItemProps>(({
|
||||
),
|
||||
Size: (
|
||||
<div
|
||||
className={`download-column-cell download-cell-truncate download-size-cell tabular-nums ${trailingColumnClass('Size')}`}
|
||||
className="download-column-cell download-cell-truncate download-size-cell tabular-nums"
|
||||
style={columnStyle('Size')}
|
||||
title={hasDownloadedAmount ? downloadedSizeLabel : completedSizeLabel}
|
||||
aria-label={hasDownloadedAmount ? downloadedSizeLabel : completedSizeLabel}
|
||||
@@ -141,7 +138,7 @@ export const DownloadItem = React.memo<DownloadItemProps>(({
|
||||
),
|
||||
Status: (
|
||||
<div
|
||||
className={`download-column-cell download-status-cell ${trailingColumnClass('Status')}`}
|
||||
className="download-column-cell download-status-cell"
|
||||
style={columnStyle('Status')}
|
||||
>
|
||||
{download.status === 'completed' ? (
|
||||
@@ -202,21 +199,21 @@ export const DownloadItem = React.memo<DownloadItemProps>(({
|
||||
</div>
|
||||
),
|
||||
Speed: (
|
||||
<div className={`download-column-cell download-cell-truncate ${trailingColumnClass('Speed')}`} style={columnStyle('Speed')}>
|
||||
<div className="download-column-cell download-cell-truncate" style={columnStyle('Speed')}>
|
||||
<span className="download-cell-content tabular-nums" title={displaySpeed}>
|
||||
{displaySpeed}
|
||||
</span>
|
||||
</div>
|
||||
),
|
||||
ETA: (
|
||||
<div className={`download-column-cell download-cell-truncate ${trailingColumnClass('ETA')}`} style={columnStyle('ETA')}>
|
||||
<div className="download-column-cell download-cell-truncate" style={columnStyle('ETA')}>
|
||||
<span className="download-cell-content tabular-nums" title={displayEta}>
|
||||
{displayEta}
|
||||
</span>
|
||||
</div>
|
||||
),
|
||||
'Date Added': (
|
||||
<div className={`download-column-cell download-cell-right download-column-date-added ${trailingColumnClass('Date Added')}`} style={columnStyle('Date Added')}>
|
||||
<div className="download-column-cell download-cell-right download-column-date-added" style={columnStyle('Date Added')}>
|
||||
<span
|
||||
className="download-cell-content download-date-value tabular-nums"
|
||||
title={download.dateAdded ? new Date(download.dateAdded).toLocaleDateString() : '-'}
|
||||
|
||||
@@ -37,6 +37,7 @@ import {
|
||||
DEFAULT_COLUMN_ALIGNMENTS,
|
||||
DEFAULT_COLUMN_ORDER,
|
||||
DEFAULT_COLUMN_WIDTHS,
|
||||
DOWNLOAD_ACTIONS_COLUMN_WIDTH,
|
||||
buildColumnGridTemplate,
|
||||
columnIndex,
|
||||
getColumnGridColumn,
|
||||
@@ -92,6 +93,11 @@ interface ColumnMenuState {
|
||||
y: number;
|
||||
}
|
||||
|
||||
interface ColumnLayoutBounds {
|
||||
left: number;
|
||||
right: number;
|
||||
}
|
||||
|
||||
export const DownloadTable: React.FC<DownloadTableProps> = ({ filter }) => {
|
||||
const { t } = useTranslation();
|
||||
const { downloads, queues, assignToQueue, openDeleteModal, redownload, moveInQueue } = useDownloadStore();
|
||||
@@ -110,7 +116,7 @@ export const DownloadTable: React.FC<DownloadTableProps> = ({ filter }) => {
|
||||
|
||||
const [contextMenu, setContextMenu] = useState<{ x: number; y: number; id: string } | null>(null);
|
||||
const [animationParent] = useAutoAnimate<HTMLDivElement>();
|
||||
const [headerAnimationParent] = useAutoAnimate<HTMLDivElement>({
|
||||
const [headerAnimationParent, setHeaderAnimationEnabled] = useAutoAnimate<HTMLDivElement>({
|
||||
duration: 140,
|
||||
easing: 'ease-out',
|
||||
});
|
||||
@@ -128,8 +134,17 @@ export const DownloadTable: React.FC<DownloadTableProps> = ({ filter }) => {
|
||||
} | null>(null);
|
||||
const headerElementsRef = useRef(new Map<DownloadTableColumnKey, HTMLDivElement>());
|
||||
const headerContainerRef = useRef<HTMLDivElement>(null);
|
||||
const headerRef = useCallback((element: HTMLDivElement | null) => {
|
||||
headerContainerRef.current = element;
|
||||
headerAnimationParent(element);
|
||||
}, [headerAnimationParent]);
|
||||
const columnDragStateRef = useRef<ColumnDragState | null>(null);
|
||||
const columnDragOrderRef = useRef<DownloadTableColumnKey[] | null>(null);
|
||||
const columnDragLayoutRef = useRef<Map<DownloadTableColumnKey, ColumnLayoutBounds> | null>(null);
|
||||
const columnDragTargetRef = useRef<HTMLDivElement | null>(null);
|
||||
const columnDragCaptureTargetRef = useRef<HTMLDivElement | null>(null);
|
||||
const columnDragCapturePointerIdRef = useRef<number | null>(null);
|
||||
const columnDragCleanupRef = useRef<(() => void) | null>(null);
|
||||
const columnDropFlashTimerRef = useRef<number | null>(null);
|
||||
const suppressHeaderClickRef = useRef(false);
|
||||
const selectedIdsRef = useRef(selectedIds);
|
||||
@@ -166,17 +181,14 @@ export const DownloadTable: React.FC<DownloadTableProps> = ({ filter }) => {
|
||||
const [, setMenuViewportVersion] = useState(0);
|
||||
const columnWidthsRef = useRef(columnWidths);
|
||||
const columnOrderRef = useRef(columnOrder);
|
||||
columnWidthsRef.current = columnWidths;
|
||||
columnOrderRef.current = columnOrder;
|
||||
columnDragOrderRef.current = columnDragOrder;
|
||||
const normalizedColumnWidths = columnWidths.map((width, index) =>
|
||||
const normalizedColumnWidths = columnWidthsRef.current.map((width, index) =>
|
||||
Math.max(COLUMN_MINIMUMS[index], width)
|
||||
);
|
||||
const orderedColumns = columnDragOrder ?? columnOrder;
|
||||
const orderedColumns = columnDragOrderRef.current ?? columnDragOrder ?? columnOrder;
|
||||
const tableGridTemplate = buildColumnGridTemplate(orderedColumns, normalizedColumnWidths);
|
||||
const tableMinWidth = normalizedColumnWidths.reduce(
|
||||
(total, width) => total + width,
|
||||
0
|
||||
DOWNLOAD_ACTIONS_COLUMN_WIDTH
|
||||
);
|
||||
const tableMinWidthWithPadding = 'calc(' + tableMinWidth + 'px + var(--download-row-padding-x) + var(--download-row-padding-x))';
|
||||
const downloadsViewRef = useRef<HTMLDivElement>(null);
|
||||
@@ -212,6 +224,15 @@ export const DownloadTable: React.FC<DownloadTableProps> = ({ filter }) => {
|
||||
};
|
||||
};
|
||||
|
||||
const captureColumnLayout = (order: DownloadTableColumnKey[]) => {
|
||||
const layout = new Map<DownloadTableColumnKey, ColumnLayoutBounds>();
|
||||
for (const key of order) {
|
||||
const element = headerElementsRef.current.get(key);
|
||||
if (element) layout.set(key, getColumnLayoutBounds(element));
|
||||
}
|
||||
return layout;
|
||||
};
|
||||
|
||||
const getColumnDropPosition = (
|
||||
key: DownloadTableColumnKey,
|
||||
pointerX: number,
|
||||
@@ -221,7 +242,7 @@ export const DownloadTable: React.FC<DownloadTableProps> = ({ filter }) => {
|
||||
const targetIndex = remainingColumns.findIndex(columnKey => {
|
||||
const element = headerElementsRef.current.get(columnKey);
|
||||
if (!element) return false;
|
||||
const bounds = getColumnLayoutBounds(element);
|
||||
const bounds = columnDragLayoutRef.current?.get(columnKey) ?? getColumnLayoutBounds(element);
|
||||
return pointerX < bounds.left + (bounds.right - bounds.left) / 2;
|
||||
});
|
||||
const dropIndex = targetIndex === -1 ? remainingColumns.length : targetIndex;
|
||||
@@ -235,78 +256,26 @@ export const DownloadTable: React.FC<DownloadTableProps> = ({ filter }) => {
|
||||
return { dropIndex, markerX };
|
||||
};
|
||||
|
||||
const handleColumnPointerDown = (key: DownloadTableColumnKey, event: React.PointerEvent<HTMLDivElement>) => {
|
||||
if (event.button !== 0) return;
|
||||
if (event.target instanceof Element && event.target.closest('.column-resize-handle, .download-column-options')) return;
|
||||
|
||||
event.currentTarget.setPointerCapture(event.pointerId);
|
||||
dragGestureRef.current = {
|
||||
key,
|
||||
pointerId: event.pointerId,
|
||||
startX: event.clientX,
|
||||
startY: event.clientY,
|
||||
active: false,
|
||||
};
|
||||
};
|
||||
|
||||
const handleColumnPointerMove = (key: DownloadTableColumnKey, event: React.PointerEvent<HTMLDivElement>) => {
|
||||
const finishColumnDrag = (key: DownloadTableColumnKey, pointerId: number, cancelled = false) => {
|
||||
const gesture = dragGestureRef.current;
|
||||
if (!gesture || gesture.key !== key || gesture.pointerId !== event.pointerId) return;
|
||||
|
||||
const distance = Math.hypot(event.clientX - gesture.startX, event.clientY - gesture.startY);
|
||||
if (!gesture.active) {
|
||||
if (distance < 5) return;
|
||||
gesture.active = true;
|
||||
suppressHeaderClickRef.current = true;
|
||||
const rect = event.currentTarget.getBoundingClientRect();
|
||||
const currentOrder = columnOrderRef.current;
|
||||
columnDragOrderRef.current = [...currentOrder];
|
||||
setColumnDragOrder([...currentOrder]);
|
||||
const { dropIndex, markerX } = getColumnDropPosition(key, event.clientX, currentOrder);
|
||||
updateColumnDragState({
|
||||
key,
|
||||
startX: gesture.startX,
|
||||
pointerX: event.clientX,
|
||||
offsetX: event.clientX - rect.left,
|
||||
top: rect.top,
|
||||
width: rect.width,
|
||||
dropIndex,
|
||||
markerX,
|
||||
});
|
||||
} else {
|
||||
const current = columnDragStateRef.current;
|
||||
if (!current) return;
|
||||
const currentOrder = columnDragOrderRef.current ?? columnOrderRef.current;
|
||||
const { dropIndex, markerX } = getColumnDropPosition(key, event.clientX, currentOrder);
|
||||
const nextOrder = reorderColumn(currentOrder, key, dropIndex);
|
||||
const orderChanged = nextOrder.some((columnKey, index) => columnKey !== currentOrder[index]);
|
||||
if (orderChanged) {
|
||||
columnDragOrderRef.current = nextOrder;
|
||||
setColumnDragOrder(nextOrder);
|
||||
}
|
||||
updateColumnDragState({
|
||||
...current,
|
||||
pointerX: event.clientX,
|
||||
dropIndex: nextOrder.indexOf(key),
|
||||
markerX,
|
||||
});
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
};
|
||||
|
||||
const finishColumnDrag = (key: DownloadTableColumnKey, event: React.PointerEvent<HTMLDivElement>, cancelled = false) => {
|
||||
const gesture = dragGestureRef.current;
|
||||
if (!gesture || gesture.key !== key || gesture.pointerId !== event.pointerId) return;
|
||||
if (!gesture || gesture.key !== key || gesture.pointerId !== pointerId) return;
|
||||
|
||||
const current = columnDragStateRef.current;
|
||||
dragGestureRef.current = null;
|
||||
updateColumnDragState(null);
|
||||
const nextOrder = columnDragOrderRef.current ?? columnOrderRef.current;
|
||||
const captureTarget = columnDragCaptureTargetRef.current;
|
||||
dragGestureRef.current = null;
|
||||
columnDragTargetRef.current = null;
|
||||
columnDragCaptureTargetRef.current = null;
|
||||
columnDragCapturePointerIdRef.current = null;
|
||||
columnDragOrderRef.current = null;
|
||||
columnDragLayoutRef.current = null;
|
||||
columnDragCleanupRef.current?.();
|
||||
columnDragCleanupRef.current = null;
|
||||
updateColumnDragState(null);
|
||||
setColumnDragOrder(null);
|
||||
if (event.currentTarget.hasPointerCapture(event.pointerId)) {
|
||||
event.currentTarget.releasePointerCapture(event.pointerId);
|
||||
setHeaderAnimationEnabled(true);
|
||||
if (captureTarget?.hasPointerCapture(pointerId)) {
|
||||
captureTarget.releasePointerCapture(pointerId);
|
||||
}
|
||||
if (cancelled || !gesture.active || !current) {
|
||||
suppressHeaderClickRef.current = false;
|
||||
@@ -318,6 +287,7 @@ export const DownloadTable: React.FC<DownloadTableProps> = ({ filter }) => {
|
||||
|
||||
if (nextOrder.join('|') === columnOrderRef.current.join('|')) return;
|
||||
|
||||
columnOrderRef.current = nextOrder;
|
||||
setColumnOrder(nextOrder);
|
||||
persistColumnOrder(nextOrder);
|
||||
if (columnDropFlashTimerRef.current !== null) {
|
||||
@@ -330,6 +300,134 @@ export const DownloadTable: React.FC<DownloadTableProps> = ({ filter }) => {
|
||||
}, 260);
|
||||
};
|
||||
|
||||
const handleColumnPointerMove = (
|
||||
key: DownloadTableColumnKey,
|
||||
pointerId: number,
|
||||
clientX: number,
|
||||
clientY: number,
|
||||
preventDefault?: () => void
|
||||
) => {
|
||||
const gesture = dragGestureRef.current;
|
||||
if (!gesture || gesture.key !== key || gesture.pointerId !== pointerId) return;
|
||||
|
||||
const distance = Math.hypot(clientX - gesture.startX, clientY - gesture.startY);
|
||||
if (!gesture.active) {
|
||||
if (distance < 5) return;
|
||||
gesture.active = true;
|
||||
suppressHeaderClickRef.current = true;
|
||||
const target = columnDragTargetRef.current;
|
||||
const rect = target?.getBoundingClientRect();
|
||||
if (!rect) return;
|
||||
// The header children are reordered in the live grid while dragging.
|
||||
// AutoAnimate's MutationObserver must not animate the same structural
|
||||
// changes that drive pointer hit-testing; it is restored on completion.
|
||||
setHeaderAnimationEnabled(false);
|
||||
const currentOrder = columnOrderRef.current;
|
||||
columnDragLayoutRef.current = captureColumnLayout(currentOrder);
|
||||
columnDragOrderRef.current = [...currentOrder];
|
||||
setColumnDragOrder([...currentOrder]);
|
||||
const { dropIndex, markerX } = getColumnDropPosition(key, clientX, currentOrder);
|
||||
updateColumnDragState({
|
||||
key,
|
||||
startX: gesture.startX,
|
||||
pointerX: clientX,
|
||||
offsetX: clientX - rect.left,
|
||||
top: rect.top,
|
||||
width: rect.width,
|
||||
dropIndex,
|
||||
markerX,
|
||||
});
|
||||
} else {
|
||||
const current = columnDragStateRef.current;
|
||||
if (!current) return;
|
||||
const currentOrder = columnDragOrderRef.current ?? columnOrderRef.current;
|
||||
const { dropIndex, markerX } = getColumnDropPosition(key, clientX, currentOrder);
|
||||
const nextOrder = reorderColumn(currentOrder, key, dropIndex);
|
||||
const orderChanged = nextOrder.some((columnKey, index) => columnKey !== currentOrder[index]);
|
||||
if (orderChanged) {
|
||||
columnDragOrderRef.current = nextOrder;
|
||||
setColumnDragOrder(nextOrder);
|
||||
}
|
||||
updateColumnDragState({
|
||||
...current,
|
||||
pointerX: clientX,
|
||||
dropIndex: nextOrder.indexOf(key),
|
||||
markerX,
|
||||
});
|
||||
}
|
||||
|
||||
preventDefault?.();
|
||||
};
|
||||
|
||||
const handleColumnPointerDown = (key: DownloadTableColumnKey, event: React.PointerEvent<HTMLDivElement>) => {
|
||||
if (event.button !== 0) return;
|
||||
if (event.target instanceof Element && event.target.closest('.column-resize-handle, .download-column-options')) return;
|
||||
|
||||
const existingGesture = dragGestureRef.current;
|
||||
if (existingGesture) {
|
||||
finishColumnDrag(existingGesture.key, existingGesture.pointerId, true);
|
||||
}
|
||||
const target = event.currentTarget;
|
||||
const pointerId = event.pointerId;
|
||||
const captureTarget = headerContainerRef.current;
|
||||
columnDragTargetRef.current = target;
|
||||
columnDragCaptureTargetRef.current = captureTarget;
|
||||
columnDragCapturePointerIdRef.current = pointerId;
|
||||
if (captureTarget) {
|
||||
try {
|
||||
// Capture on the stable grid parent, never on a column that is
|
||||
// structurally moved during the live reorder.
|
||||
captureTarget.setPointerCapture(pointerId);
|
||||
} catch {
|
||||
// Window listeners below still handle browsers that reject capture
|
||||
// after the pointer has already been cancelled.
|
||||
}
|
||||
}
|
||||
columnDragLayoutRef.current = null;
|
||||
dragGestureRef.current = {
|
||||
key,
|
||||
pointerId,
|
||||
startX: event.clientX,
|
||||
startY: event.clientY,
|
||||
active: false,
|
||||
};
|
||||
const pointerMove = (pointerEvent: PointerEvent) => {
|
||||
if (pointerEvent.pointerId !== pointerId) return;
|
||||
handleColumnPointerMove(
|
||||
key,
|
||||
pointerEvent.pointerId,
|
||||
pointerEvent.clientX,
|
||||
pointerEvent.clientY,
|
||||
() => pointerEvent.preventDefault()
|
||||
);
|
||||
};
|
||||
const pointerUp = (pointerEvent: PointerEvent) => {
|
||||
if (pointerEvent.pointerId === pointerId) {
|
||||
finishColumnDrag(key, pointerEvent.pointerId);
|
||||
}
|
||||
};
|
||||
const pointerCancel = (pointerEvent: PointerEvent) => {
|
||||
if (pointerEvent.pointerId === pointerId) {
|
||||
finishColumnDrag(key, pointerEvent.pointerId, true);
|
||||
}
|
||||
};
|
||||
const lostPointerCapture = (pointerEvent: PointerEvent) => {
|
||||
if (pointerEvent.pointerId === pointerId) {
|
||||
finishColumnDrag(key, pointerEvent.pointerId, true);
|
||||
}
|
||||
};
|
||||
window.addEventListener('pointermove', pointerMove);
|
||||
window.addEventListener('pointerup', pointerUp);
|
||||
window.addEventListener('pointercancel', pointerCancel);
|
||||
captureTarget?.addEventListener('lostpointercapture', lostPointerCapture);
|
||||
columnDragCleanupRef.current = () => {
|
||||
window.removeEventListener('pointermove', pointerMove);
|
||||
window.removeEventListener('pointerup', pointerUp);
|
||||
window.removeEventListener('pointercancel', pointerCancel);
|
||||
captureTarget?.removeEventListener('lostpointercapture', lostPointerCapture);
|
||||
};
|
||||
};
|
||||
|
||||
const startColumnResize = (key: DownloadTableColumnKey, event: React.PointerEvent<HTMLDivElement>) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
@@ -411,6 +509,7 @@ export const DownloadTable: React.FC<DownloadTableProps> = ({ filter }) => {
|
||||
const alignments = { ...DEFAULT_COLUMN_ALIGNMENTS };
|
||||
setColumnWidths(widths);
|
||||
columnWidthsRef.current = widths;
|
||||
columnOrderRef.current = order;
|
||||
setColumnOrder(order);
|
||||
setColumnAlignments(alignments);
|
||||
persistColumnWidths(widths);
|
||||
@@ -440,6 +539,16 @@ export const DownloadTable: React.FC<DownloadTableProps> = ({ filter }) => {
|
||||
|
||||
useEffect(() => () => {
|
||||
resizeCleanupRef.current?.();
|
||||
columnDragCleanupRef.current?.();
|
||||
columnDragCleanupRef.current = null;
|
||||
const captureTarget = columnDragCaptureTargetRef.current;
|
||||
const capturePointerId = columnDragCapturePointerIdRef.current;
|
||||
columnDragTargetRef.current = null;
|
||||
columnDragCaptureTargetRef.current = null;
|
||||
columnDragCapturePointerIdRef.current = null;
|
||||
if (captureTarget && capturePointerId !== null && captureTarget.hasPointerCapture(capturePointerId)) {
|
||||
captureTarget.releasePointerCapture(capturePointerId);
|
||||
}
|
||||
if (columnDropFlashTimerRef.current !== null) {
|
||||
window.clearTimeout(columnDropFlashTimerRef.current);
|
||||
}
|
||||
@@ -448,9 +557,21 @@ export const DownloadTable: React.FC<DownloadTableProps> = ({ filter }) => {
|
||||
useEffect(() => {
|
||||
const cancelColumnDrag = () => {
|
||||
if (!dragGestureRef.current) return;
|
||||
columnDragCleanupRef.current?.();
|
||||
columnDragCleanupRef.current = null;
|
||||
const captureTarget = columnDragCaptureTargetRef.current;
|
||||
const capturePointerId = columnDragCapturePointerIdRef.current;
|
||||
columnDragTargetRef.current = null;
|
||||
columnDragCaptureTargetRef.current = null;
|
||||
columnDragCapturePointerIdRef.current = null;
|
||||
if (captureTarget && capturePointerId !== null && captureTarget.hasPointerCapture(capturePointerId)) {
|
||||
captureTarget.releasePointerCapture(capturePointerId);
|
||||
}
|
||||
dragGestureRef.current = null;
|
||||
columnDragStateRef.current = null;
|
||||
columnDragOrderRef.current = null;
|
||||
columnDragLayoutRef.current = null;
|
||||
setHeaderAnimationEnabled(true);
|
||||
setColumnDragState(null);
|
||||
setColumnDragOrder(null);
|
||||
suppressHeaderClickRef.current = false;
|
||||
@@ -923,10 +1044,7 @@ export const DownloadTable: React.FC<DownloadTableProps> = ({ filter }) => {
|
||||
<div className="downloads-table flex-1 flex flex-col">
|
||||
<div className="download-table-scroll">
|
||||
<div
|
||||
ref={element => {
|
||||
headerContainerRef.current = element;
|
||||
headerAnimationParent(element);
|
||||
}}
|
||||
ref={headerRef}
|
||||
className={`download-table-header ${columnDragState ? 'is-column-dragging' : ''}`}
|
||||
style={{ gridTemplateColumns: tableGridTemplate, minWidth: tableMinWidthWithPadding }}
|
||||
>
|
||||
@@ -958,10 +1076,6 @@ export const DownloadTable: React.FC<DownloadTableProps> = ({ filter }) => {
|
||||
openColumnMenu(key, event.clientX, event.clientY);
|
||||
}}
|
||||
onPointerDown={event => handleColumnPointerDown(key, event)}
|
||||
onPointerMove={event => handleColumnPointerMove(key, event)}
|
||||
onPointerUp={event => finishColumnDrag(key, event)}
|
||||
onPointerCancel={event => finishColumnDrag(key, event, true)}
|
||||
onLostPointerCapture={event => finishColumnDrag(key, event, true)}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
|
||||
+6
-14
@@ -1927,6 +1927,7 @@ html[data-list-density="relaxed"] {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
padding-inline: var(--download-column-padding-x);
|
||||
padding-right: calc(var(--download-column-padding-x) + 22px);
|
||||
cursor: grab;
|
||||
transition: background-color 120ms ease, opacity 120ms ease, box-shadow 120ms ease;
|
||||
}
|
||||
@@ -1937,7 +1938,6 @@ html[data-list-density="relaxed"] {
|
||||
|
||||
.download-column-header.is-dragging {
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.download-column-header.is-drop-flashing {
|
||||
@@ -2126,10 +2126,6 @@ html[data-list-density="relaxed"] .download-ghost-row {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.download-row:hover .download-column-cell--trailing .download-cell-content {
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
.download-file-cell {
|
||||
direction: ltr;
|
||||
text-align: left;
|
||||
@@ -2205,17 +2201,13 @@ html[data-list-density="relaxed"] .download-ghost-row {
|
||||
}
|
||||
|
||||
.download-row-actions {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
right: var(--download-row-padding-x);
|
||||
left: auto;
|
||||
grid-column: 8;
|
||||
align-self: stretch;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
z-index: 2;
|
||||
transform: translateY(-50%);
|
||||
padding-inline-start: 4px;
|
||||
border-radius: 6px;
|
||||
background: hsl(var(--surface-overlay) / 0.94);
|
||||
max-width: 100%;
|
||||
padding-inline: 4px;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.download-column-menu {
|
||||
|
||||
@@ -56,7 +56,7 @@ describe('download table column preferences', () => {
|
||||
|
||||
it('keeps user widths fixed while reserving a shared trailing spacer track', () => {
|
||||
expect(buildColumnGridTemplate([...DEFAULT_COLUMN_ORDER], [...DEFAULT_COLUMN_WIDTHS])).toBe(
|
||||
'340px 100px 220px 100px 80px minmax(0, 1fr) 170px'
|
||||
'340px 100px 220px 100px 80px minmax(0, 1fr) 170px 32px'
|
||||
);
|
||||
expect(getColumnGridColumn('Date Added', [...DEFAULT_COLUMN_ORDER])).toBe('7');
|
||||
expect(getColumnGridColumn('Date Added', ['Date Added', ...DEFAULT_COLUMN_ORDER.slice(0, -1)])).toBeUndefined();
|
||||
|
||||
@@ -14,6 +14,7 @@ export const DEFAULT_COLUMN_ORDER = [
|
||||
|
||||
export const DEFAULT_COLUMN_WIDTHS = [340, 100, 220, 100, 80, 170] as const;
|
||||
export const COLUMN_MINIMUMS = [160, 58, 92, 58, 48, 144] as const;
|
||||
export const DOWNLOAD_ACTIONS_COLUMN_WIDTH = 32;
|
||||
|
||||
export const COLUMN_WIDTHS_STORAGE_KEY = 'firelink-download-column-widths';
|
||||
export const COLUMN_ORDER_STORAGE_KEY = 'firelink-download-column-order';
|
||||
@@ -98,6 +99,7 @@ export const buildColumnGridTemplate = (
|
||||
...orderedWidths.slice(0, -1).map(width => `${width}px`),
|
||||
'minmax(0, 1fr)',
|
||||
`${orderedWidths[orderedWidths.length - 1]}px`,
|
||||
`${DOWNLOAD_ACTIONS_COLUMN_WIDTH}px`,
|
||||
].join(' ');
|
||||
};
|
||||
|
||||
|
||||
@@ -18,9 +18,10 @@ export type KeychainAccessReadiness = {
|
||||
};
|
||||
|
||||
// The semantic app version can remain unchanged across release-candidate and
|
||||
// packaging rebuilds. Use the build identity so an updated binary cannot skip
|
||||
// Firelink's explanation and invoke the OS prompt directly. The policy epoch
|
||||
// remains only as a safe fallback for builds created outside the Git checkout.
|
||||
// packaging rebuilds. The Tauri packaging hook appends a fresh artifact nonce
|
||||
// to the build identity, so replacing the binary cannot skip Firelink's
|
||||
// explanation and invoke the OS prompt directly. The policy epoch remains
|
||||
// only as a safe fallback for builds created outside the Git checkout.
|
||||
const KEYCHAIN_CONSENT_POLICY_VERSION = '2';
|
||||
const buildId = typeof import.meta.env.VITE_BUILD_ID === 'string'
|
||||
? import.meta.env.VITE_BUILD_ID.trim()
|
||||
|
||||
Reference in New Issue
Block a user