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 fs from 'node:fs';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import { spawnSync } from 'node:child_process';
|
import { spawnSync } from 'node:child_process';
|
||||||
|
import { randomUUID } from 'node:crypto';
|
||||||
import { fileURLToPath } from 'node:url';
|
import { fileURLToPath } from 'node:url';
|
||||||
|
|
||||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||||
@@ -41,6 +42,14 @@ function runNpmScript(script) {
|
|||||||
run('npm', ['run', 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/stage-engines.js']);
|
||||||
run(process.execPath, ['scripts/verify-binaries.js', '--staged']);
|
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 { lazy, Suspense, useCallback, useEffect, useRef, useState } from "react";
|
||||||
import { Sidebar, SidebarFilter } from "./components/Sidebar";
|
import { Sidebar, SidebarFilter } from "./components/Sidebar";
|
||||||
import { DownloadTable } from "./components/DownloadTable";
|
import { DownloadTable } from "./components/DownloadTable";
|
||||||
|
import { KeychainPermissionModal } from './components/KeychainPermissionModal';
|
||||||
import { extractValidDownloadUrls } from './utils/url';
|
import { extractValidDownloadUrls } from './utils/url';
|
||||||
import { readClipboardDownloadUrls } from './utils/clipboard';
|
import { readClipboardDownloadUrls } from './utils/clipboard';
|
||||||
import { listenEvent as listen, invokeCommand as invoke } from "./ipc";
|
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 => ({
|
const DeleteConfirmationModal = lazy(() => import('./components/DeleteConfirmationModal').then(module => ({
|
||||||
default: module.DeleteConfirmationModal,
|
default: module.DeleteConfirmationModal,
|
||||||
})));
|
})));
|
||||||
const KeychainPermissionModal = lazy(() => import('./components/KeychainPermissionModal').then(module => ({
|
|
||||||
default: module.KeychainPermissionModal,
|
|
||||||
})));
|
|
||||||
|
|
||||||
const preloadPageChunks = async () => {
|
const preloadPageChunks = async () => {
|
||||||
for (const load of pageChunkLoaders) {
|
for (const load of pageChunkLoaders) {
|
||||||
try {
|
try {
|
||||||
@@ -500,14 +497,17 @@ function App() {
|
|||||||
let changed = false;
|
let changed = false;
|
||||||
if (deferKeychainHydration) {
|
if (deferKeychainHydration) {
|
||||||
settings.setKeychainAccessReady(false);
|
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
|
// This token is already owned by the backend and does not access
|
||||||
// the OS credential store. Render our explanation before any native
|
// the OS credential store. Render our explanation before any native
|
||||||
// Keychain/Credential Manager prompt can be user-triggered.
|
// Keychain/Credential Manager prompt can be user-triggered.
|
||||||
await settings.hydrateSessionPairingToken(isStartupActive);
|
await settings.hydrateSessionPairingToken(isStartupActive);
|
||||||
if (!active) return;
|
if (!active) return;
|
||||||
if (showKeychainPrompt) {
|
|
||||||
settings.setShowKeychainModal(true);
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
// The backend keeps credential-store access disabled for every new
|
// The backend keeps credential-store access disabled for every new
|
||||||
// process. Arm it only after the persisted startup decision has
|
// process. Arm it only after the persisted startup decision has
|
||||||
|
|||||||
@@ -98,13 +98,10 @@ export const DownloadItem = React.memo<DownloadItemProps>(({
|
|||||||
gridColumn: getColumnGridColumn(key, columnOrder),
|
gridColumn: getColumnGridColumn(key, columnOrder),
|
||||||
} as React.CSSProperties);
|
} as React.CSSProperties);
|
||||||
|
|
||||||
const trailingColumnClass = (key: DownloadTableColumnKey) =>
|
|
||||||
key === columnOrder[columnOrder.length - 1] ? 'download-column-cell--trailing' : '';
|
|
||||||
|
|
||||||
const cells: Record<DownloadTableColumnKey, React.ReactNode> = {
|
const cells: Record<DownloadTableColumnKey, React.ReactNode> = {
|
||||||
'File Name': (
|
'File Name': (
|
||||||
<div
|
<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')}
|
style={columnStyle('File Name')}
|
||||||
>
|
>
|
||||||
<div className="download-cell-content">
|
<div className="download-cell-content">
|
||||||
@@ -119,7 +116,7 @@ export const DownloadItem = React.memo<DownloadItemProps>(({
|
|||||||
),
|
),
|
||||||
Size: (
|
Size: (
|
||||||
<div
|
<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')}
|
style={columnStyle('Size')}
|
||||||
title={hasDownloadedAmount ? downloadedSizeLabel : completedSizeLabel}
|
title={hasDownloadedAmount ? downloadedSizeLabel : completedSizeLabel}
|
||||||
aria-label={hasDownloadedAmount ? downloadedSizeLabel : completedSizeLabel}
|
aria-label={hasDownloadedAmount ? downloadedSizeLabel : completedSizeLabel}
|
||||||
@@ -141,7 +138,7 @@ export const DownloadItem = React.memo<DownloadItemProps>(({
|
|||||||
),
|
),
|
||||||
Status: (
|
Status: (
|
||||||
<div
|
<div
|
||||||
className={`download-column-cell download-status-cell ${trailingColumnClass('Status')}`}
|
className="download-column-cell download-status-cell"
|
||||||
style={columnStyle('Status')}
|
style={columnStyle('Status')}
|
||||||
>
|
>
|
||||||
{download.status === 'completed' ? (
|
{download.status === 'completed' ? (
|
||||||
@@ -202,21 +199,21 @@ export const DownloadItem = React.memo<DownloadItemProps>(({
|
|||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
Speed: (
|
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}>
|
<span className="download-cell-content tabular-nums" title={displaySpeed}>
|
||||||
{displaySpeed}
|
{displaySpeed}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
ETA: (
|
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}>
|
<span className="download-cell-content tabular-nums" title={displayEta}>
|
||||||
{displayEta}
|
{displayEta}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
'Date Added': (
|
'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
|
<span
|
||||||
className="download-cell-content download-date-value tabular-nums"
|
className="download-cell-content download-date-value tabular-nums"
|
||||||
title={download.dateAdded ? new Date(download.dateAdded).toLocaleDateString() : '-'}
|
title={download.dateAdded ? new Date(download.dateAdded).toLocaleDateString() : '-'}
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ import {
|
|||||||
DEFAULT_COLUMN_ALIGNMENTS,
|
DEFAULT_COLUMN_ALIGNMENTS,
|
||||||
DEFAULT_COLUMN_ORDER,
|
DEFAULT_COLUMN_ORDER,
|
||||||
DEFAULT_COLUMN_WIDTHS,
|
DEFAULT_COLUMN_WIDTHS,
|
||||||
|
DOWNLOAD_ACTIONS_COLUMN_WIDTH,
|
||||||
buildColumnGridTemplate,
|
buildColumnGridTemplate,
|
||||||
columnIndex,
|
columnIndex,
|
||||||
getColumnGridColumn,
|
getColumnGridColumn,
|
||||||
@@ -92,6 +93,11 @@ interface ColumnMenuState {
|
|||||||
y: number;
|
y: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface ColumnLayoutBounds {
|
||||||
|
left: number;
|
||||||
|
right: number;
|
||||||
|
}
|
||||||
|
|
||||||
export const DownloadTable: React.FC<DownloadTableProps> = ({ filter }) => {
|
export const DownloadTable: React.FC<DownloadTableProps> = ({ filter }) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { downloads, queues, assignToQueue, openDeleteModal, redownload, moveInQueue } = useDownloadStore();
|
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 [contextMenu, setContextMenu] = useState<{ x: number; y: number; id: string } | null>(null);
|
||||||
const [animationParent] = useAutoAnimate<HTMLDivElement>();
|
const [animationParent] = useAutoAnimate<HTMLDivElement>();
|
||||||
const [headerAnimationParent] = useAutoAnimate<HTMLDivElement>({
|
const [headerAnimationParent, setHeaderAnimationEnabled] = useAutoAnimate<HTMLDivElement>({
|
||||||
duration: 140,
|
duration: 140,
|
||||||
easing: 'ease-out',
|
easing: 'ease-out',
|
||||||
});
|
});
|
||||||
@@ -128,8 +134,17 @@ export const DownloadTable: React.FC<DownloadTableProps> = ({ filter }) => {
|
|||||||
} | null>(null);
|
} | null>(null);
|
||||||
const headerElementsRef = useRef(new Map<DownloadTableColumnKey, HTMLDivElement>());
|
const headerElementsRef = useRef(new Map<DownloadTableColumnKey, HTMLDivElement>());
|
||||||
const headerContainerRef = useRef<HTMLDivElement>(null);
|
const headerContainerRef = useRef<HTMLDivElement>(null);
|
||||||
|
const headerRef = useCallback((element: HTMLDivElement | null) => {
|
||||||
|
headerContainerRef.current = element;
|
||||||
|
headerAnimationParent(element);
|
||||||
|
}, [headerAnimationParent]);
|
||||||
const columnDragStateRef = useRef<ColumnDragState | null>(null);
|
const columnDragStateRef = useRef<ColumnDragState | null>(null);
|
||||||
const columnDragOrderRef = useRef<DownloadTableColumnKey[] | 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 columnDropFlashTimerRef = useRef<number | null>(null);
|
||||||
const suppressHeaderClickRef = useRef(false);
|
const suppressHeaderClickRef = useRef(false);
|
||||||
const selectedIdsRef = useRef(selectedIds);
|
const selectedIdsRef = useRef(selectedIds);
|
||||||
@@ -166,17 +181,14 @@ export const DownloadTable: React.FC<DownloadTableProps> = ({ filter }) => {
|
|||||||
const [, setMenuViewportVersion] = useState(0);
|
const [, setMenuViewportVersion] = useState(0);
|
||||||
const columnWidthsRef = useRef(columnWidths);
|
const columnWidthsRef = useRef(columnWidths);
|
||||||
const columnOrderRef = useRef(columnOrder);
|
const columnOrderRef = useRef(columnOrder);
|
||||||
columnWidthsRef.current = columnWidths;
|
const normalizedColumnWidths = columnWidthsRef.current.map((width, index) =>
|
||||||
columnOrderRef.current = columnOrder;
|
|
||||||
columnDragOrderRef.current = columnDragOrder;
|
|
||||||
const normalizedColumnWidths = columnWidths.map((width, index) =>
|
|
||||||
Math.max(COLUMN_MINIMUMS[index], width)
|
Math.max(COLUMN_MINIMUMS[index], width)
|
||||||
);
|
);
|
||||||
const orderedColumns = columnDragOrder ?? columnOrder;
|
const orderedColumns = columnDragOrderRef.current ?? columnDragOrder ?? columnOrder;
|
||||||
const tableGridTemplate = buildColumnGridTemplate(orderedColumns, normalizedColumnWidths);
|
const tableGridTemplate = buildColumnGridTemplate(orderedColumns, normalizedColumnWidths);
|
||||||
const tableMinWidth = normalizedColumnWidths.reduce(
|
const tableMinWidth = normalizedColumnWidths.reduce(
|
||||||
(total, width) => total + width,
|
(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 tableMinWidthWithPadding = 'calc(' + tableMinWidth + 'px + var(--download-row-padding-x) + var(--download-row-padding-x))';
|
||||||
const downloadsViewRef = useRef<HTMLDivElement>(null);
|
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 = (
|
const getColumnDropPosition = (
|
||||||
key: DownloadTableColumnKey,
|
key: DownloadTableColumnKey,
|
||||||
pointerX: number,
|
pointerX: number,
|
||||||
@@ -221,7 +242,7 @@ export const DownloadTable: React.FC<DownloadTableProps> = ({ filter }) => {
|
|||||||
const targetIndex = remainingColumns.findIndex(columnKey => {
|
const targetIndex = remainingColumns.findIndex(columnKey => {
|
||||||
const element = headerElementsRef.current.get(columnKey);
|
const element = headerElementsRef.current.get(columnKey);
|
||||||
if (!element) return false;
|
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;
|
return pointerX < bounds.left + (bounds.right - bounds.left) / 2;
|
||||||
});
|
});
|
||||||
const dropIndex = targetIndex === -1 ? remainingColumns.length : targetIndex;
|
const dropIndex = targetIndex === -1 ? remainingColumns.length : targetIndex;
|
||||||
@@ -235,78 +256,26 @@ export const DownloadTable: React.FC<DownloadTableProps> = ({ filter }) => {
|
|||||||
return { dropIndex, markerX };
|
return { dropIndex, markerX };
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleColumnPointerDown = (key: DownloadTableColumnKey, event: React.PointerEvent<HTMLDivElement>) => {
|
const finishColumnDrag = (key: DownloadTableColumnKey, pointerId: number, cancelled = false) => {
|
||||||
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 gesture = dragGestureRef.current;
|
const gesture = dragGestureRef.current;
|
||||||
if (!gesture || gesture.key !== key || gesture.pointerId !== event.pointerId) return;
|
if (!gesture || gesture.key !== key || gesture.pointerId !== 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;
|
|
||||||
|
|
||||||
const current = columnDragStateRef.current;
|
const current = columnDragStateRef.current;
|
||||||
dragGestureRef.current = null;
|
|
||||||
updateColumnDragState(null);
|
|
||||||
const nextOrder = columnDragOrderRef.current ?? columnOrderRef.current;
|
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;
|
columnDragOrderRef.current = null;
|
||||||
|
columnDragLayoutRef.current = null;
|
||||||
|
columnDragCleanupRef.current?.();
|
||||||
|
columnDragCleanupRef.current = null;
|
||||||
|
updateColumnDragState(null);
|
||||||
setColumnDragOrder(null);
|
setColumnDragOrder(null);
|
||||||
if (event.currentTarget.hasPointerCapture(event.pointerId)) {
|
setHeaderAnimationEnabled(true);
|
||||||
event.currentTarget.releasePointerCapture(event.pointerId);
|
if (captureTarget?.hasPointerCapture(pointerId)) {
|
||||||
|
captureTarget.releasePointerCapture(pointerId);
|
||||||
}
|
}
|
||||||
if (cancelled || !gesture.active || !current) {
|
if (cancelled || !gesture.active || !current) {
|
||||||
suppressHeaderClickRef.current = false;
|
suppressHeaderClickRef.current = false;
|
||||||
@@ -318,6 +287,7 @@ export const DownloadTable: React.FC<DownloadTableProps> = ({ filter }) => {
|
|||||||
|
|
||||||
if (nextOrder.join('|') === columnOrderRef.current.join('|')) return;
|
if (nextOrder.join('|') === columnOrderRef.current.join('|')) return;
|
||||||
|
|
||||||
|
columnOrderRef.current = nextOrder;
|
||||||
setColumnOrder(nextOrder);
|
setColumnOrder(nextOrder);
|
||||||
persistColumnOrder(nextOrder);
|
persistColumnOrder(nextOrder);
|
||||||
if (columnDropFlashTimerRef.current !== null) {
|
if (columnDropFlashTimerRef.current !== null) {
|
||||||
@@ -330,6 +300,134 @@ export const DownloadTable: React.FC<DownloadTableProps> = ({ filter }) => {
|
|||||||
}, 260);
|
}, 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>) => {
|
const startColumnResize = (key: DownloadTableColumnKey, event: React.PointerEvent<HTMLDivElement>) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
@@ -411,6 +509,7 @@ export const DownloadTable: React.FC<DownloadTableProps> = ({ filter }) => {
|
|||||||
const alignments = { ...DEFAULT_COLUMN_ALIGNMENTS };
|
const alignments = { ...DEFAULT_COLUMN_ALIGNMENTS };
|
||||||
setColumnWidths(widths);
|
setColumnWidths(widths);
|
||||||
columnWidthsRef.current = widths;
|
columnWidthsRef.current = widths;
|
||||||
|
columnOrderRef.current = order;
|
||||||
setColumnOrder(order);
|
setColumnOrder(order);
|
||||||
setColumnAlignments(alignments);
|
setColumnAlignments(alignments);
|
||||||
persistColumnWidths(widths);
|
persistColumnWidths(widths);
|
||||||
@@ -440,6 +539,16 @@ export const DownloadTable: React.FC<DownloadTableProps> = ({ filter }) => {
|
|||||||
|
|
||||||
useEffect(() => () => {
|
useEffect(() => () => {
|
||||||
resizeCleanupRef.current?.();
|
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) {
|
if (columnDropFlashTimerRef.current !== null) {
|
||||||
window.clearTimeout(columnDropFlashTimerRef.current);
|
window.clearTimeout(columnDropFlashTimerRef.current);
|
||||||
}
|
}
|
||||||
@@ -448,9 +557,21 @@ export const DownloadTable: React.FC<DownloadTableProps> = ({ filter }) => {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const cancelColumnDrag = () => {
|
const cancelColumnDrag = () => {
|
||||||
if (!dragGestureRef.current) return;
|
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;
|
dragGestureRef.current = null;
|
||||||
columnDragStateRef.current = null;
|
columnDragStateRef.current = null;
|
||||||
columnDragOrderRef.current = null;
|
columnDragOrderRef.current = null;
|
||||||
|
columnDragLayoutRef.current = null;
|
||||||
|
setHeaderAnimationEnabled(true);
|
||||||
setColumnDragState(null);
|
setColumnDragState(null);
|
||||||
setColumnDragOrder(null);
|
setColumnDragOrder(null);
|
||||||
suppressHeaderClickRef.current = false;
|
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="downloads-table flex-1 flex flex-col">
|
||||||
<div className="download-table-scroll">
|
<div className="download-table-scroll">
|
||||||
<div
|
<div
|
||||||
ref={element => {
|
ref={headerRef}
|
||||||
headerContainerRef.current = element;
|
|
||||||
headerAnimationParent(element);
|
|
||||||
}}
|
|
||||||
className={`download-table-header ${columnDragState ? 'is-column-dragging' : ''}`}
|
className={`download-table-header ${columnDragState ? 'is-column-dragging' : ''}`}
|
||||||
style={{ gridTemplateColumns: tableGridTemplate, minWidth: tableMinWidthWithPadding }}
|
style={{ gridTemplateColumns: tableGridTemplate, minWidth: tableMinWidthWithPadding }}
|
||||||
>
|
>
|
||||||
@@ -958,10 +1076,6 @@ export const DownloadTable: React.FC<DownloadTableProps> = ({ filter }) => {
|
|||||||
openColumnMenu(key, event.clientX, event.clientY);
|
openColumnMenu(key, event.clientX, event.clientY);
|
||||||
}}
|
}}
|
||||||
onPointerDown={event => handleColumnPointerDown(key, event)}
|
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
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|||||||
+6
-14
@@ -1927,6 +1927,7 @@ html[data-list-density="relaxed"] {
|
|||||||
min-width: 0;
|
min-width: 0;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
padding-inline: var(--download-column-padding-x);
|
padding-inline: var(--download-column-padding-x);
|
||||||
|
padding-right: calc(var(--download-column-padding-x) + 22px);
|
||||||
cursor: grab;
|
cursor: grab;
|
||||||
transition: background-color 120ms ease, opacity 120ms ease, box-shadow 120ms ease;
|
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 {
|
.download-column-header.is-dragging {
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
pointer-events: none;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.download-column-header.is-drop-flashing {
|
.download-column-header.is-drop-flashing {
|
||||||
@@ -2126,10 +2126,6 @@ html[data-list-density="relaxed"] .download-ghost-row {
|
|||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.download-row:hover .download-column-cell--trailing .download-cell-content {
|
|
||||||
visibility: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.download-file-cell {
|
.download-file-cell {
|
||||||
direction: ltr;
|
direction: ltr;
|
||||||
text-align: left;
|
text-align: left;
|
||||||
@@ -2205,17 +2201,13 @@ html[data-list-density="relaxed"] .download-ghost-row {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.download-row-actions {
|
.download-row-actions {
|
||||||
position: absolute;
|
grid-column: 8;
|
||||||
top: 50%;
|
align-self: stretch;
|
||||||
right: var(--download-row-padding-x);
|
align-items: center;
|
||||||
left: auto;
|
justify-content: flex-end;
|
||||||
z-index: 2;
|
z-index: 2;
|
||||||
transform: translateY(-50%);
|
|
||||||
padding-inline-start: 4px;
|
|
||||||
border-radius: 6px;
|
|
||||||
background: hsl(var(--surface-overlay) / 0.94);
|
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
padding-inline: 4px;
|
padding: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.download-column-menu {
|
.download-column-menu {
|
||||||
|
|||||||
@@ -56,7 +56,7 @@ describe('download table column preferences', () => {
|
|||||||
|
|
||||||
it('keeps user widths fixed while reserving a shared trailing spacer track', () => {
|
it('keeps user widths fixed while reserving a shared trailing spacer track', () => {
|
||||||
expect(buildColumnGridTemplate([...DEFAULT_COLUMN_ORDER], [...DEFAULT_COLUMN_WIDTHS])).toBe(
|
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', [...DEFAULT_COLUMN_ORDER])).toBe('7');
|
||||||
expect(getColumnGridColumn('Date Added', ['Date Added', ...DEFAULT_COLUMN_ORDER.slice(0, -1)])).toBeUndefined();
|
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 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 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_WIDTHS_STORAGE_KEY = 'firelink-download-column-widths';
|
||||||
export const COLUMN_ORDER_STORAGE_KEY = 'firelink-download-column-order';
|
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`),
|
...orderedWidths.slice(0, -1).map(width => `${width}px`),
|
||||||
'minmax(0, 1fr)',
|
'minmax(0, 1fr)',
|
||||||
`${orderedWidths[orderedWidths.length - 1]}px`,
|
`${orderedWidths[orderedWidths.length - 1]}px`,
|
||||||
|
`${DOWNLOAD_ACTIONS_COLUMN_WIDTH}px`,
|
||||||
].join(' ');
|
].join(' ');
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -18,9 +18,10 @@ export type KeychainAccessReadiness = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// The semantic app version can remain unchanged across release-candidate and
|
// The semantic app version can remain unchanged across release-candidate and
|
||||||
// packaging rebuilds. Use the build identity so an updated binary cannot skip
|
// packaging rebuilds. The Tauri packaging hook appends a fresh artifact nonce
|
||||||
// Firelink's explanation and invoke the OS prompt directly. The policy epoch
|
// to the build identity, so replacing the binary cannot skip Firelink's
|
||||||
// remains only as a safe fallback for builds created outside the Git checkout.
|
// 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 KEYCHAIN_CONSENT_POLICY_VERSION = '2';
|
||||||
const buildId = typeof import.meta.env.VITE_BUILD_ID === 'string'
|
const buildId = typeof import.meta.env.VITE_BUILD_ID === 'string'
|
||||||
? import.meta.env.VITE_BUILD_ID.trim()
|
? import.meta.env.VITE_BUILD_ID.trim()
|
||||||
|
|||||||
Reference in New Issue
Block a user