mirror of
https://github.com/gitbutlerapp/gitbutler.git
synced 2025-01-07 02:11:11 +03:00
Merge pull request #3904 from gitbutlerapp/improve-file-id-selection
Sanitize file preview code
This commit is contained in:
commit
a4878b234d
@ -7,7 +7,7 @@
|
||||
import { selectFilesInList } from '$lib/utils/selectFilesInList';
|
||||
import { maybeMoveSelection } from '$lib/utils/selection';
|
||||
import { getCommitStore } from '$lib/vbranches/contexts';
|
||||
import { FileIdSelection, fileKey } from '$lib/vbranches/fileIdSelection';
|
||||
import { FileIdSelection, stringifyFileKey } from '$lib/vbranches/fileIdSelection';
|
||||
import { sortLikeFileTree } from '$lib/vbranches/filetree';
|
||||
import type { AnyFile } from '$lib/vbranches/types';
|
||||
|
||||
@ -76,13 +76,13 @@
|
||||
{readonly}
|
||||
{isUnapplied}
|
||||
showCheckbox={showCheckboxes}
|
||||
selected={$fileIdSelection.includes(fileKey(file.id, $commit?.id))}
|
||||
selected={$fileIdSelection.includes(stringifyFileKey(file.id, $commit?.id))}
|
||||
on:click={(e) => {
|
||||
selectFilesInList(e, file, fileIdSelection, sortedFiles, allowMultiple, $commit);
|
||||
}}
|
||||
on:keydown={(e) => {
|
||||
e.preventDefault();
|
||||
maybeMoveSelection(e.key, sortedFiles, fileIdSelection);
|
||||
maybeMoveSelection(e.key, file, sortedFiles, fileIdSelection);
|
||||
}}
|
||||
/>
|
||||
{/each}
|
||||
|
@ -7,11 +7,9 @@
|
||||
import { persisted } from '$lib/persisted/persisted';
|
||||
import { SETTINGS, type Settings } from '$lib/settings/userSettings';
|
||||
import { getContext, getContextStoreBySymbol, createContextStore } from '$lib/utils/context';
|
||||
import { isDefined } from '$lib/utils/typeguards';
|
||||
import {
|
||||
createLocalContextStore,
|
||||
createRemoteContextStore,
|
||||
createSelectedFiles,
|
||||
createUnknownCommitsStore,
|
||||
createUpstreamContextStore
|
||||
} from '$lib/vbranches/contexts';
|
||||
@ -46,22 +44,13 @@
|
||||
const unknownCommits = createUnknownCommitsStore([]);
|
||||
$: unknownCommits.set($upstreamCommits.filter((c) => !c.relatedTo));
|
||||
|
||||
const project = getContext(Project);
|
||||
|
||||
const fileIdSelection = new FileIdSelection();
|
||||
setContext(FileIdSelection, fileIdSelection);
|
||||
|
||||
const selectedFiles = createSelectedFiles([]);
|
||||
$: if ($fileIdSelection.length == 0) selectedFiles.set([]);
|
||||
$: if ($fileIdSelection.length > 0 && fileIdSelection.only().commitId == 'undefined') {
|
||||
selectedFiles.set(
|
||||
$fileIdSelection
|
||||
.map((fileId) => branch.files.find((f) => f.id + '|' + undefined == fileId))
|
||||
.filter(isDefined)
|
||||
);
|
||||
}
|
||||
$: selectedFile = fileIdSelection.selectedFile(branch.files, project.id);
|
||||
|
||||
$: displayedFile = $selectedFiles.length == 1 ? $selectedFiles[0] : undefined;
|
||||
|
||||
const project = getContext(Project);
|
||||
const userSettings = getContextStoreBySymbol<Settings>(SETTINGS);
|
||||
|
||||
let rsViewport: HTMLElement;
|
||||
@ -79,39 +68,41 @@
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="wrapper" data-tauri-drag-region class:file-selected={displayedFile}>
|
||||
<div class="wrapper" data-tauri-drag-region>
|
||||
<BranchCard {isUnapplied} {commitBoxOpen} bind:isLaneCollapsed />
|
||||
|
||||
{#if displayedFile}
|
||||
<div
|
||||
class="file-preview resize-viewport"
|
||||
bind:this={rsViewport}
|
||||
in:slide={{ duration: 180, easing: quintOut, axis: 'x' }}
|
||||
style:width={`${fileWidth || $defaultFileWidthRem}rem`}
|
||||
>
|
||||
<FileCard
|
||||
conflicted={displayedFile.conflicted}
|
||||
file={displayedFile}
|
||||
{isUnapplied}
|
||||
readonly={displayedFile instanceof RemoteFile}
|
||||
selectable={$commitBoxOpen && !isUnapplied}
|
||||
on:close={() => {
|
||||
fileIdSelection.clear();
|
||||
}}
|
||||
/>
|
||||
<Resizer
|
||||
viewport={rsViewport}
|
||||
direction="right"
|
||||
minWidth={400}
|
||||
defaultLineColor="var(--clr-border-2)"
|
||||
on:width={(e) => {
|
||||
fileWidth = e.detail / (16 * $userSettings.zoom);
|
||||
lscache.set(fileWidthKey + branch.id, fileWidth, 7 * 1440); // 7 day ttl
|
||||
$defaultFileWidthRem = fileWidth;
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
{#await $selectedFile then selected}
|
||||
{#if selected}
|
||||
<div
|
||||
class="file-preview resize-viewport"
|
||||
bind:this={rsViewport}
|
||||
in:slide={{ duration: 180, easing: quintOut, axis: 'x' }}
|
||||
style:width={`${fileWidth || $defaultFileWidthRem}rem`}
|
||||
>
|
||||
<FileCard
|
||||
conflicted={selected.conflicted}
|
||||
file={selected}
|
||||
{isUnapplied}
|
||||
readonly={selected instanceof RemoteFile}
|
||||
selectable={$commitBoxOpen && !isUnapplied}
|
||||
on:close={() => {
|
||||
fileIdSelection.clear();
|
||||
}}
|
||||
/>
|
||||
<Resizer
|
||||
viewport={rsViewport}
|
||||
direction="right"
|
||||
minWidth={400}
|
||||
defaultLineColor="var(--clr-border-2)"
|
||||
on:width={(e) => {
|
||||
fileWidth = e.detail / (16 * $userSettings.zoom);
|
||||
lscache.set(fileWidthKey + branch.id, fileWidth, 7 * 1440); // 7 day ttl
|
||||
$defaultFileWidthRem = fileWidth;
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
{/await}
|
||||
</div>
|
||||
|
||||
<style lang="postcss">
|
||||
|
@ -15,8 +15,7 @@
|
||||
import { getTimeAgo } from '$lib/utils/timeAgo';
|
||||
import { openExternalUrl } from '$lib/utils/url';
|
||||
import { BranchController } from '$lib/vbranches/branchController';
|
||||
import { createCommitStore, getSelectedFiles } from '$lib/vbranches/contexts';
|
||||
import { FileIdSelection } from '$lib/vbranches/fileIdSelection';
|
||||
import { createCommitStore } from '$lib/vbranches/contexts';
|
||||
import { listRemoteCommitFiles } from '$lib/vbranches/remoteCommits';
|
||||
import {
|
||||
RemoteCommit,
|
||||
@ -41,8 +40,6 @@
|
||||
const branchController = getContext(BranchController);
|
||||
const baseBranch = getContextStore(BaseBranch);
|
||||
const project = getContext(Project);
|
||||
const selectedFiles = getSelectedFiles();
|
||||
const fileIdSelection = getContext(FileIdSelection);
|
||||
const advancedCommitOperations = featureAdvancedCommitOperations();
|
||||
|
||||
const commitStore = createCommitStore(commit);
|
||||
@ -55,12 +52,6 @@
|
||||
let files: RemoteFile[] = [];
|
||||
let showDetails = false;
|
||||
|
||||
$: selectedFile =
|
||||
$fileIdSelection.length == 1 &&
|
||||
fileIdSelection.only().commitId == commit.id &&
|
||||
files.find((f) => f.id == fileIdSelection.only().fileId);
|
||||
$: if (selectedFile) selectedFiles.set([selectedFile]);
|
||||
|
||||
async function loadFiles() {
|
||||
files = await listRemoteCommitFiles(project.id, commit.id);
|
||||
}
|
||||
@ -320,7 +311,7 @@
|
||||
|
||||
{#if showDetails}
|
||||
<div class="files-container">
|
||||
<BranchFilesList title="Files" {files} {isUnapplied} />
|
||||
<BranchFilesList title="Files" {files} {isUnapplied} readonly={type == 'upstream'} />
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
@ -1,15 +1,15 @@
|
||||
<script lang="ts">
|
||||
import FileContextMenu from './FileContextMenu.svelte';
|
||||
import FileStatusIcons from './FileStatusIcons.svelte';
|
||||
import { Project } from '$lib/backend/projects';
|
||||
import Checkbox from '$lib/components/Checkbox.svelte';
|
||||
import { draggable } from '$lib/dragging/draggable';
|
||||
import { DraggableFile } from '$lib/dragging/draggables';
|
||||
import { getVSIFileIcon } from '$lib/ext-icons';
|
||||
import { getContext, maybeGetContextStore } from '$lib/utils/context';
|
||||
import { updateFocus } from '$lib/utils/selection';
|
||||
import { isDefined } from '$lib/utils/typeguards';
|
||||
import { getCommitStore, getSelectedFiles } from '$lib/vbranches/contexts';
|
||||
import { FileIdSelection, fileKey } from '$lib/vbranches/fileIdSelection';
|
||||
import { getCommitStore } from '$lib/vbranches/contexts';
|
||||
import { FileIdSelection } from '$lib/vbranches/fileIdSelection';
|
||||
import { Ownership } from '$lib/vbranches/ownership';
|
||||
import { Branch, type AnyFile } from '$lib/vbranches/types';
|
||||
import { onDestroy } from 'svelte';
|
||||
@ -24,9 +24,11 @@
|
||||
const branch = maybeGetContextStore(Branch);
|
||||
const selectedOwnership: Writable<Ownership> | undefined = maybeGetContextStore(Ownership);
|
||||
const fileIdSelection = getContext(FileIdSelection);
|
||||
const selectedFiles = getSelectedFiles();
|
||||
const project = getContext(Project);
|
||||
const commit = getCommitStore();
|
||||
|
||||
$: selectedFiles = fileIdSelection.files($branch?.files || [], project.id);
|
||||
|
||||
let checked = false;
|
||||
let indeterminate = false;
|
||||
let draggableElt: HTMLDivElement;
|
||||
@ -67,15 +69,17 @@
|
||||
data-locked={file.locked}
|
||||
on:click
|
||||
on:keydown
|
||||
on:dragstart={() => {
|
||||
on:dragstart={async () => {
|
||||
// Reset selection if the file being dragged is not in the selected list
|
||||
if ($fileIdSelection.length > 0 && !fileIdSelection.has(file.id, $commit?.id)) {
|
||||
fileIdSelection.clear();
|
||||
fileIdSelection.add(file.id, $commit?.id);
|
||||
}
|
||||
|
||||
if ($selectedFiles.length > 0) {
|
||||
$selectedFiles.forEach((f) => {
|
||||
const files = await $selectedFiles;
|
||||
|
||||
if (files.length > 0) {
|
||||
files.forEach((f) => {
|
||||
if (f.locked) {
|
||||
const lockedElement = document.getElementById(`file-${f.id}`);
|
||||
|
||||
@ -95,22 +99,22 @@
|
||||
draggableElt.classList.remove('locked-file-animation');
|
||||
}
|
||||
}}
|
||||
role="button"
|
||||
tabindex="0"
|
||||
use:draggable={{
|
||||
data: new DraggableFile($branch?.id || '', file, $commit, selectedFiles),
|
||||
data: $selectedFiles.then(
|
||||
(files) => new DraggableFile($branch?.id || '', file, $commit, files)
|
||||
),
|
||||
disabled: readonly || isUnapplied,
|
||||
viewportId: 'board-viewport',
|
||||
selector: '.selected-draggable'
|
||||
}}
|
||||
role="button"
|
||||
tabindex="0"
|
||||
on:contextmenu|preventDefault={(e) => {
|
||||
const files = fileIdSelection.has(file.id, $commit?.id)
|
||||
? $fileIdSelection
|
||||
.map((key) => $selectedFiles?.find((f) => fileKey(f.id, $commit?.id) == key))
|
||||
.filter(isDefined)
|
||||
: [file];
|
||||
if (files.length > 0) popupMenu.openByMouse(e, { files });
|
||||
else console.error('No files selected');
|
||||
on:contextmenu|preventDefault={async (e) => {
|
||||
if (fileIdSelection.has(file.id, $commit?.id)) {
|
||||
popupMenu.openByMouse(e, { files: await $selectedFiles });
|
||||
} else {
|
||||
popupMenu.openByMouse(e, { files: [file] });
|
||||
}
|
||||
}}
|
||||
>
|
||||
{#if showCheckbox}
|
||||
|
@ -8,7 +8,6 @@
|
||||
import { SETTINGS, type Settings } from '$lib/settings/userSettings';
|
||||
import { getRemoteBranchData } from '$lib/stores/remoteBranches';
|
||||
import { getContext, getContextStore, getContextStoreBySymbol } from '$lib/utils/context';
|
||||
import { createSelectedFiles } from '$lib/vbranches/contexts';
|
||||
import { FileIdSelection } from '$lib/vbranches/fileIdSelection';
|
||||
import { BaseBranch, type RemoteBranch } from '$lib/vbranches/types';
|
||||
import lscache from 'lscache';
|
||||
@ -25,7 +24,7 @@
|
||||
const fileIdSelection = new FileIdSelection();
|
||||
setContext(FileIdSelection, fileIdSelection);
|
||||
|
||||
const selectedFiles = createSelectedFiles([]);
|
||||
$: selectedFile = fileIdSelection.selectedFile([], project.id);
|
||||
|
||||
const defaultBranchWidthRem = 30;
|
||||
const laneWidthKey = 'branchPreviewLaneWidth';
|
||||
@ -34,8 +33,6 @@
|
||||
let rsViewport: HTMLDivElement;
|
||||
let laneWidth: number;
|
||||
|
||||
$: selected = $selectedFiles.length == 1 ? $selectedFiles[0] : undefined;
|
||||
|
||||
onMount(() => {
|
||||
laneWidth = lscache.get(laneWidthKey);
|
||||
});
|
||||
@ -86,18 +83,20 @@
|
||||
/>
|
||||
</div>
|
||||
<div class="base__right">
|
||||
{#if selected}
|
||||
<FileCard
|
||||
conflicted={selected.conflicted}
|
||||
file={selected}
|
||||
isUnapplied={false}
|
||||
readonly={true}
|
||||
on:close={() => {
|
||||
console.log(selected);
|
||||
fileIdSelection.clear();
|
||||
}}
|
||||
/>
|
||||
{/if}
|
||||
{#await $selectedFile then selected}
|
||||
{#if selected}
|
||||
<FileCard
|
||||
conflicted={selected.conflicted}
|
||||
file={selected}
|
||||
isUnapplied={false}
|
||||
readonly={true}
|
||||
on:close={() => {
|
||||
console.log(selected);
|
||||
fileIdSelection.clear();
|
||||
}}
|
||||
/>
|
||||
{/if}
|
||||
{/await}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -119,6 +118,7 @@
|
||||
overflow-x: auto;
|
||||
align-items: flex-start;
|
||||
padding: var(--size-12) var(--size-12) var(--size-12) var(--size-6);
|
||||
width: 50rem;
|
||||
}
|
||||
|
||||
.branch-preview {
|
||||
|
@ -1,10 +1,11 @@
|
||||
import { dzRegistry } from './dropzone';
|
||||
import type { DraggableCommit, DraggableFile, DraggableHunk } from './draggables';
|
||||
|
||||
export type Draggable = DraggableFile | DraggableHunk | DraggableCommit;
|
||||
export interface DraggableConfig {
|
||||
readonly selector?: string;
|
||||
readonly disabled?: boolean;
|
||||
readonly data?: DraggableFile | DraggableHunk | DraggableCommit;
|
||||
readonly data?: Draggable | Promise<Draggable>;
|
||||
readonly viewportId?: string;
|
||||
}
|
||||
|
||||
@ -118,63 +119,63 @@ export function draggable(node: HTMLElement, initialOpts: DraggableConfig) {
|
||||
document.body.appendChild(clone);
|
||||
|
||||
// activate destination zones
|
||||
dzRegistry
|
||||
.filter(([_node, dz]) => dz.accepts(opts.data))
|
||||
.forEach(([target, dz]) => {
|
||||
function onDrop(e: DragEvent) {
|
||||
e.preventDefault();
|
||||
dz.onDrop(opts.data);
|
||||
}
|
||||
dzRegistry.forEach(async ([target, dz]) => {
|
||||
if (!dz.accepts(await opts.data)) return;
|
||||
|
||||
function onDragEnter(e: DragEvent) {
|
||||
e.preventDefault();
|
||||
target.classList.add(dz.hover);
|
||||
}
|
||||
async function onDrop(e: DragEvent) {
|
||||
e.preventDefault();
|
||||
dz.onDrop(await opts.data);
|
||||
}
|
||||
|
||||
function onDragLeave(e: DragEvent) {
|
||||
e.preventDefault();
|
||||
target.classList.remove(dz.hover);
|
||||
}
|
||||
function onDragEnter(e: DragEvent) {
|
||||
e.preventDefault();
|
||||
target.classList.add(dz.hover);
|
||||
}
|
||||
|
||||
function onDragOver(e: DragEvent) {
|
||||
e.preventDefault();
|
||||
}
|
||||
function onDragLeave(e: DragEvent) {
|
||||
e.preventDefault();
|
||||
target.classList.remove(dz.hover);
|
||||
}
|
||||
|
||||
// keep track of listeners so that we can remove them later
|
||||
if (onDropListeners.has(target)) {
|
||||
onDropListeners.get(target)!.push(onDrop);
|
||||
} else {
|
||||
onDropListeners.set(target, [onDrop]);
|
||||
}
|
||||
function onDragOver(e: DragEvent) {
|
||||
e.preventDefault();
|
||||
}
|
||||
|
||||
if (onDragEnterListeners.has(target)) {
|
||||
onDragEnterListeners.get(target)!.push(onDragEnter);
|
||||
} else {
|
||||
onDragEnterListeners.set(target, [onDragEnter]);
|
||||
}
|
||||
// keep track of listeners so that we can remove them later
|
||||
if (onDropListeners.has(target)) {
|
||||
onDropListeners.get(target)!.push(onDrop);
|
||||
} else {
|
||||
onDropListeners.set(target, [onDrop]);
|
||||
}
|
||||
|
||||
if (onDragLeaveListeners.has(target)) {
|
||||
onDragLeaveListeners.get(target)!.push(onDragLeave);
|
||||
} else {
|
||||
onDragLeaveListeners.set(target, [onDragLeave]);
|
||||
}
|
||||
if (onDragEnterListeners.has(target)) {
|
||||
onDragEnterListeners.get(target)!.push(onDragEnter);
|
||||
} else {
|
||||
onDragEnterListeners.set(target, [onDragEnter]);
|
||||
}
|
||||
|
||||
if (onDragOverListeners.has(target)) {
|
||||
onDragOverListeners.get(target)!.push(onDragOver);
|
||||
} else {
|
||||
onDragOverListeners.set(target, [onDragOver]);
|
||||
}
|
||||
if (onDragLeaveListeners.has(target)) {
|
||||
onDragLeaveListeners.get(target)!.push(onDragLeave);
|
||||
} else {
|
||||
onDragLeaveListeners.set(target, [onDragLeave]);
|
||||
}
|
||||
|
||||
// https://stackoverflow.com/questions/14203734/dragend-dragenter-and-dragleave-firing-off-immediately-when-i-drag
|
||||
setTimeout(() => {
|
||||
target.classList.add(dz.active);
|
||||
}, 10);
|
||||
if (onDragOverListeners.has(target)) {
|
||||
onDragOverListeners.get(target)!.push(onDragOver);
|
||||
} else {
|
||||
onDragOverListeners.set(target, [onDragOver]);
|
||||
}
|
||||
|
||||
target.addEventListener('drop', onDrop);
|
||||
target.addEventListener('dragenter', onDragEnter);
|
||||
target.addEventListener('dragleave', onDragLeave);
|
||||
target.addEventListener('dragover', onDragOver);
|
||||
});
|
||||
// https://stackoverflow.com/questions/14203734/dragend-dragenter-and-dragleave-firing-off-immediately-when-i-drag
|
||||
setTimeout(() => {
|
||||
target.classList.add(dz.active);
|
||||
}, 10);
|
||||
|
||||
target.addEventListener('drop', onDrop);
|
||||
target.addEventListener('dragenter', onDragEnter);
|
||||
target.addEventListener('dragleave', onDragLeave);
|
||||
target.addEventListener('dragover', onDragOver);
|
||||
});
|
||||
|
||||
// Get chromium to fire dragover & drop events
|
||||
// https://stackoverflow.com/questions/6481094/html5-drag-and-drop-ondragover-not-firing-in-chrome/6483205#6483205
|
||||
@ -195,26 +196,25 @@ export function draggable(node: HTMLElement, initialOpts: DraggableConfig) {
|
||||
});
|
||||
|
||||
// deactivate destination zones
|
||||
dzRegistry
|
||||
.filter(([_node, dz]) => dz.accepts(opts.data))
|
||||
.forEach(([node, dz]) => {
|
||||
// remove all listeners
|
||||
onDropListeners.get(node)?.forEach((listener) => {
|
||||
node.removeEventListener('drop', listener);
|
||||
});
|
||||
onDragEnterListeners.get(node)?.forEach((listener) => {
|
||||
node.removeEventListener('dragenter', listener);
|
||||
});
|
||||
onDragLeaveListeners.get(node)?.forEach((listener) => {
|
||||
node.removeEventListener('dragleave', listener);
|
||||
});
|
||||
onDragOverListeners.get(node)?.forEach((listener) => {
|
||||
node.removeEventListener('dragover', listener);
|
||||
});
|
||||
|
||||
node.classList.remove(dz.active);
|
||||
node.classList.remove(dz.hover);
|
||||
dzRegistry.forEach(async ([node, dz]) => {
|
||||
if (!dz.accepts(await opts.data)) return;
|
||||
// remove all listeners
|
||||
onDropListeners.get(node)?.forEach((listener) => {
|
||||
node.removeEventListener('drop', listener);
|
||||
});
|
||||
onDragEnterListeners.get(node)?.forEach((listener) => {
|
||||
node.removeEventListener('dragenter', listener);
|
||||
});
|
||||
onDragLeaveListeners.get(node)?.forEach((listener) => {
|
||||
node.removeEventListener('dragleave', listener);
|
||||
});
|
||||
onDragOverListeners.get(node)?.forEach((listener) => {
|
||||
node.removeEventListener('dragover', listener);
|
||||
});
|
||||
|
||||
node.classList.remove(dz.active);
|
||||
node.classList.remove(dz.hover);
|
||||
});
|
||||
|
||||
e.stopPropagation();
|
||||
}
|
||||
|
@ -1,4 +1,3 @@
|
||||
import { get, type Readable } from 'svelte/store';
|
||||
import type { AnyCommit, AnyFile, Commit, Hunk, RemoteCommit } from '../vbranches/types';
|
||||
|
||||
export function nonDraggable() {
|
||||
@ -20,12 +19,11 @@ export class DraggableFile {
|
||||
public readonly branchId: string,
|
||||
public file: AnyFile,
|
||||
public commit: AnyCommit | undefined,
|
||||
private selection: Readable<AnyFile[]> | undefined
|
||||
private selection: AnyFile[] | undefined
|
||||
) {}
|
||||
|
||||
get files(): AnyFile[] {
|
||||
const selection = this.selection ? get(this.selection) : undefined;
|
||||
if (selection && selection.length > 0) return selection;
|
||||
if (this.selection && this.selection.length > 0) return this.selection;
|
||||
return [this.file];
|
||||
}
|
||||
}
|
||||
|
12
app/src/lib/utils/groupBy.ts
Normal file
12
app/src/lib/utils/groupBy.ts
Normal file
@ -0,0 +1,12 @@
|
||||
// TODO: Look into what browser versions we need to support and if we can use Object.groupBy
|
||||
export function groupBy<T>(array: T[], callback: (item: T) => string) {
|
||||
const groups: { [key: string]: T[] } = {};
|
||||
|
||||
for (const item of array) {
|
||||
const key = callback(item);
|
||||
if (!groups[key]) groups[key] = [];
|
||||
groups[key].push(item);
|
||||
}
|
||||
|
||||
return groups;
|
||||
}
|
@ -1,4 +1,4 @@
|
||||
import { fileKey, type FileIdSelection } from '$lib/vbranches/fileIdSelection';
|
||||
import { stringifyFileKey, type FileIdSelection } from '$lib/vbranches/fileIdSelection';
|
||||
import { get } from 'svelte/store';
|
||||
import type { AnyCommit, AnyFile } from '$lib/vbranches/types';
|
||||
|
||||
@ -22,7 +22,7 @@ export function selectFilesInList(
|
||||
}
|
||||
} else if (e.shiftKey && allowMultiple) {
|
||||
const initiallySelectedIndex = sortedFiles.findIndex(
|
||||
(file) => fileKey(file.id, undefined) == selectedFileIds[0]
|
||||
(file) => stringifyFileKey(file.id, undefined) == selectedFileIds[0]
|
||||
);
|
||||
|
||||
// detect the direction of the selection
|
||||
@ -40,7 +40,7 @@ export function selectFilesInList(
|
||||
) + 1
|
||||
);
|
||||
|
||||
selectedFileIds = updatedSelection.map((f) => fileKey(f.id, commit?.id));
|
||||
selectedFileIds = updatedSelection.map((f) => stringifyFileKey(f.id, commit?.id));
|
||||
|
||||
if (selectionDirection === 'down') {
|
||||
selectedFileIds = selectedFileIds.reverse();
|
||||
@ -51,7 +51,7 @@ export function selectFilesInList(
|
||||
if (selectedFileIds.length == 1 && isAlreadySelected) {
|
||||
fileIdSelection.clear();
|
||||
} else {
|
||||
fileIdSelection.set([fileKey(file.id, commit?.id)]);
|
||||
fileIdSelection.set([stringifyFileKey(file.id, commit?.id)]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -35,22 +35,22 @@ export function updateFocus(
|
||||
fileIdSelection: FileIdSelection,
|
||||
commitId?: string
|
||||
) {
|
||||
if (fileIdSelection.length != 1) return;
|
||||
const selected = fileIdSelection.only();
|
||||
if (!selected) return;
|
||||
if (selected.fileId == file.id && selected.commitId == commitId) elt.focus();
|
||||
}
|
||||
|
||||
export function maybeMoveSelection(
|
||||
key: string,
|
||||
file: AnyFile,
|
||||
files: AnyFile[],
|
||||
selectedFileIds: FileIdSelection
|
||||
fileIdSelection: FileIdSelection
|
||||
) {
|
||||
if (key != 'ArrowUp' && key != 'ArrowDown') return;
|
||||
if (selectedFileIds.length == 0) return;
|
||||
|
||||
const newSelection = getFileByKey(key, selectedFileIds.only().fileId, files);
|
||||
const newSelection = getFileByKey(key, file.id, files);
|
||||
if (newSelection) {
|
||||
selectedFileIds.clear();
|
||||
selectedFileIds.add(newSelection.id);
|
||||
fileIdSelection.clear();
|
||||
fileIdSelection.add(newSelection.id);
|
||||
}
|
||||
}
|
||||
|
@ -1,6 +1,5 @@
|
||||
import { buildContextStore } from '$lib/utils/context';
|
||||
import type { AnyCommit, AnyFile, Commit, RemoteCommit } from './types';
|
||||
import type { Writable } from 'svelte/store';
|
||||
import type { AnyCommit, Commit, RemoteCommit } from './types';
|
||||
|
||||
// When we can't use type for context objects we build typed getter/setter pairs
|
||||
// to avoid using symbols explicitly.
|
||||
@ -12,10 +11,6 @@ export const [getUpstreamCommits, createUpstreamContextStore] =
|
||||
buildContextStore<RemoteCommit[]>('upstreamCommits');
|
||||
export const [getUnknownCommits, createUnknownCommitsStore] =
|
||||
buildContextStore<RemoteCommit[]>('unknownCommits');
|
||||
export const [getSelectedFiles, createSelectedFiles] = buildContextStore<
|
||||
AnyFile[],
|
||||
Writable<AnyFile[]>
|
||||
>('selectedFiles');
|
||||
export const [getCommitStore, createCommitStore] = buildContextStore<AnyCommit | undefined>(
|
||||
'commit'
|
||||
);
|
||||
|
@ -1,7 +1,26 @@
|
||||
export function fileKey(fileId: string, commitId?: string) {
|
||||
import { isDefined } from '$lib/utils/typeguards';
|
||||
import { listRemoteCommitFiles } from '$lib/vbranches/remoteCommits';
|
||||
import { derived } from 'svelte/store';
|
||||
import type { AnyFile, LocalFile } from '$lib/vbranches/types';
|
||||
|
||||
export interface FileKey {
|
||||
fileId: string;
|
||||
commitId?: string;
|
||||
}
|
||||
|
||||
export function stringifyFileKey(fileId: string, commitId?: string) {
|
||||
return fileId + '|' + commitId;
|
||||
}
|
||||
|
||||
export function parseFileKey(fileKeyString: string): FileKey {
|
||||
const [fileId, commitId] = fileKeyString.split('|');
|
||||
|
||||
return {
|
||||
fileId,
|
||||
commitId: commitId == 'undefined' ? undefined : commitId
|
||||
};
|
||||
}
|
||||
|
||||
export type SelectedFile = {
|
||||
context?: string;
|
||||
fileId: string;
|
||||
@ -13,9 +32,9 @@ export class FileIdSelection {
|
||||
private value: string[];
|
||||
private callbacks: CallBack[];
|
||||
|
||||
constructor() {
|
||||
constructor(value: FileKey[] = []) {
|
||||
this.callbacks = [];
|
||||
this.value = [];
|
||||
this.value = value.map((key) => stringifyFileKey(key.fileId, key.commitId));
|
||||
}
|
||||
|
||||
subscribe(callback: (value: string[]) => void) {
|
||||
@ -29,16 +48,16 @@ export class FileIdSelection {
|
||||
}
|
||||
|
||||
add(fileId: string, commitId?: string) {
|
||||
this.value.push(fileKey(fileId, commitId));
|
||||
this.value.push(stringifyFileKey(fileId, commitId));
|
||||
this.emit();
|
||||
}
|
||||
|
||||
has(fileId: string, commitId?: string) {
|
||||
return this.value.includes(fileKey(fileId, commitId));
|
||||
return this.value.includes(stringifyFileKey(fileId, commitId));
|
||||
}
|
||||
|
||||
remove(fileId: string, commitId?: string) {
|
||||
this.value = this.value.filter((key) => key != fileKey(fileId, commitId));
|
||||
this.value = this.value.filter((key) => key != stringifyFileKey(fileId, commitId));
|
||||
this.emit();
|
||||
}
|
||||
|
||||
@ -62,12 +81,42 @@ export class FileIdSelection {
|
||||
}
|
||||
}
|
||||
|
||||
only() {
|
||||
const [fileId, commitId] = this.value[0].split('|');
|
||||
return { fileId, commitId };
|
||||
only(): FileKey | undefined {
|
||||
if (this.value.length == 0) return;
|
||||
const fileKey = parseFileKey(this.value[0]);
|
||||
return fileKey;
|
||||
}
|
||||
|
||||
selectedFile(localFiles: LocalFile[], branchId: string) {
|
||||
return derived(this, async (value): Promise<AnyFile | undefined> => {
|
||||
if (value.length != 1) return;
|
||||
const fileKey = parseFileKey(value[0]);
|
||||
return await findFileByKey(localFiles, branchId, fileKey);
|
||||
});
|
||||
}
|
||||
|
||||
files(localFiles: LocalFile[], branchId: string) {
|
||||
return derived(this, async (value) => {
|
||||
const files = await Promise.all(
|
||||
value.map(async (fileKey) => {
|
||||
return await findFileByKey(localFiles, branchId, parseFileKey(fileKey));
|
||||
})
|
||||
);
|
||||
|
||||
return files.filter(isDefined);
|
||||
});
|
||||
}
|
||||
|
||||
get length() {
|
||||
return this.value.length;
|
||||
}
|
||||
}
|
||||
|
||||
export async function findFileByKey(localFiles: LocalFile[], projectId: string, key: FileKey) {
|
||||
if (key.commitId) {
|
||||
const remoteFiles = await listRemoteCommitFiles(projectId, key.commitId);
|
||||
return remoteFiles.find((file) => file.id == key.fileId);
|
||||
} else {
|
||||
return localFiles.find((file) => file.id == key.fileId);
|
||||
}
|
||||
}
|
||||
|
@ -1,4 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { Project } from '$lib/backend/projects';
|
||||
import BaseBranch from '$lib/components/BaseBranch.svelte';
|
||||
import FileCard from '$lib/components/FileCard.svelte';
|
||||
import FullviewLoading from '$lib/components/FullviewLoading.svelte';
|
||||
@ -7,7 +8,6 @@
|
||||
import { SETTINGS, type Settings } from '$lib/settings/userSettings';
|
||||
import { getContext, getContextStoreBySymbol } from '$lib/utils/context';
|
||||
import { BaseBranchService } from '$lib/vbranches/baseBranch';
|
||||
import { createSelectedFiles } from '$lib/vbranches/contexts';
|
||||
import { FileIdSelection } from '$lib/vbranches/fileIdSelection';
|
||||
import lscache from 'lscache';
|
||||
import { onMount, setContext } from 'svelte';
|
||||
@ -17,17 +17,17 @@
|
||||
|
||||
const baseBranchService = getContext(BaseBranchService);
|
||||
const baseBranch = baseBranchService.base;
|
||||
const project = getContext(Project);
|
||||
|
||||
const fileIdSelection = new FileIdSelection();
|
||||
setContext(FileIdSelection, fileIdSelection);
|
||||
|
||||
const selectedFiles = createSelectedFiles([]);
|
||||
$: selectedFile = fileIdSelection.selectedFile([], project.id);
|
||||
|
||||
let rsViewport: HTMLDivElement;
|
||||
let laneWidth: number;
|
||||
|
||||
$: error$ = baseBranchService.error$;
|
||||
$: selected = $selectedFiles.length == 1 ? $selectedFiles[0] : undefined;
|
||||
|
||||
onMount(() => {
|
||||
laneWidth = lscache.get(laneWidthKey);
|
||||
@ -61,17 +61,19 @@
|
||||
/>
|
||||
</div>
|
||||
<div class="base__right">
|
||||
{#if selected}
|
||||
<FileCard
|
||||
conflicted={selected.conflicted}
|
||||
file={selected}
|
||||
isUnapplied={false}
|
||||
readonly={true}
|
||||
on:close={() => {
|
||||
fileIdSelection.clear();
|
||||
}}
|
||||
/>
|
||||
{/if}
|
||||
{#await $selectedFile then selected}
|
||||
{#if selected}
|
||||
<FileCard
|
||||
conflicted={selected.conflicted}
|
||||
file={selected}
|
||||
isUnapplied={false}
|
||||
readonly={true}
|
||||
on:close={() => {
|
||||
fileIdSelection.clear();
|
||||
}}
|
||||
/>
|
||||
{/if}
|
||||
{/await}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
Loading…
Reference in New Issue
Block a user