Skip to content

Commit b9ed344

Browse files
committed
feat: support file table header sorting
1 parent 00339ed commit b9ed344

6 files changed

Lines changed: 235 additions & 20 deletions

File tree

‎ui/koko/components/FileManagement/localPane.vue‎

Lines changed: 18 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
<script setup lang="ts">
22
import type { FileTransferEndpoint, FileTransferEndpointRef } from "@jumpserver/connectors-core";
33
import type { DropdownMenuItem } from "@nuxt/ui";
4+
import type { SftpFileSortColumn } from "#koko/composables/sftp/file-manager/filePresentation";
45
import type {
56
SftpTransferDropPayload,
67
SftpTransferSourcePayload
@@ -14,6 +15,7 @@ import SftpPaneDropOverlay from "#koko/components/FileManagement/pane/SftpPaneDr
1415
import SftpPaneFileTable from "#koko/components/FileManagement/pane/SftpPaneFileTable.vue";
1516
import SftpPaneSelectionBar from "#koko/components/FileManagement/pane/SftpPaneSelectionBar.vue";
1617
import SftpPaneTableSkeleton from "#koko/components/FileManagement/pane/SftpPaneTableSkeleton.vue";
18+
import { sortFileEntries } from "#koko/composables/sftp/file-manager/filePresentation";
1719
import { SFTP_ENTRY_NAME_MAX_LENGTH, sftpEntryNameError } from "#koko/composables/sftp/file-manager/sftpEntryName";
1820
import {
1921
buildTransferSourcePayload,
@@ -65,6 +67,8 @@ const toast = useToast();
6567
const { addErrorToast } = useErrorToast();
6668
const setupOpen = ref(false);
6769
const search = ref("");
70+
const sortBy = shallowRef<SftpFileSortColumn>("name");
71+
const sortDirection = shallowRef<"asc" | "desc">("asc");
6872
const rootEl = ref<HTMLElement | null>(null);
6973
const promptOpen = ref(false);
7074
const promptName = ref("");
@@ -117,14 +121,11 @@ const localTransferEndpoint = useLocalFileTransferEndpoint({
117121
const { showHiddenFiles, filterHiddenEntries } = useSftpShowHiddenFiles();
118122
const visibleEntries = computed(() => {
119123
const query = search.value.trim().toLowerCase();
120-
return filterHiddenEntries(entries.value)
121-
.filter((entry) => !query || entry.name.toLowerCase().includes(query))
122-
.sort((left, right) => {
123-
if (left.name === "..") return -1;
124-
if (right.name === "..") return 1;
125-
if (left.is_dir !== right.is_dir) return Number(right.is_dir) - Number(left.is_dir);
126-
return left.name.localeCompare(right.name, undefined, { numeric: true, sensitivity: "base" });
127-
});
124+
return sortFileEntries(
125+
filterHiddenEntries(entries.value).filter((entry) => !query || entry.name.toLowerCase().includes(query)),
126+
sortBy.value,
127+
sortDirection.value
128+
);
128129
});
129130
const selection = useSftpPaneSelection<SftpFileEntry>({ visibleEntries });
130131
const {
@@ -171,6 +172,12 @@ const revealLabel = computed(() =>
171172
t(isWindows.value ? "koko.localFile.revealInFileExplorer" : "koko.localFile.revealInFinder")
172173
);
173174
175+
function changeSort(column: SftpFileSortColumn, direction: "asc" | "desc"): void {
176+
sortBy.value = column;
177+
sortDirection.value = direction;
178+
selection.resetSelectionAnchor();
179+
}
180+
174181
function clearTransferredSelection(names: string[], sourcePath: string, revision: number): void {
175182
selection.clearTransferredSelection(names, sourcePath, revision, currentPath.value);
176183
}
@@ -493,13 +500,16 @@ defineExpose({
493500
<SftpPaneFileTable
494501
class="min-h-0 flex-1"
495502
:entries="visibleEntries"
503+
:sort-by="sortBy"
504+
:sort-direction="sortDirection"
496505
:selected-names="selectedEntries.map((entry) => entry.name)"
497506
:highlighted-names="highlightedNames"
498507
:select-all-state="selectAllState"
499508
:list-key="currentPath"
500509
:refreshing="loading && entries.length > 0"
501510
show-status-bar
502511
draggable
512+
@sort="changeSort"
503513
@select="selectEntry"
504514
@toggle="toggleEntry"
505515
@toggle-all="toggleAllVisible"

‎ui/koko/components/FileManagement/pane.vue‎

Lines changed: 18 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import type {
44
FileTransferEndpoint,
55
FileTransferEndpointRef
66
} from "@jumpserver/connectors-core";
7+
import type { SftpFileSortColumn } from "#koko/composables/sftp/file-manager/filePresentation";
78
import type { BrowserUploadSelection } from "#koko/composables/sftp/file-manager/transfer";
89
import type {
910
SftpTransferDropPayload,
@@ -17,6 +18,7 @@ import SftpPaneFileTable from "#koko/components/FileManagement/pane/SftpPaneFile
1718
import SftpPaneSelectionBar from "#koko/components/FileManagement/pane/SftpPaneSelectionBar.vue";
1819
import SftpPaneTableSkeleton from "#koko/components/FileManagement/pane/SftpPaneTableSkeleton.vue";
1920
import SftpRemotePaneToolbar from "#koko/components/FileManagement/pane/SftpRemotePaneToolbar.vue";
21+
import { sortFileEntries } from "#koko/composables/sftp/file-manager/filePresentation";
2022
import {
2123
buildTransferSourcePayload,
2224
collectBrowserUploadSelection,
@@ -85,19 +87,19 @@ const manager = useSftpFileManager(
8587
const canTransferFiles = computed(() => Boolean(props.transferEndpoint) && !props.compact);
8688
const activeTransferDragSourceId = useState<string | null>("sftp-active-transfer-drag-source", () => null);
8789
const search = ref("");
90+
const sortBy = shallowRef<SftpFileSortColumn>("name");
91+
const sortDirection = shallowRef<"asc" | "desc">("asc");
8892
const contextMenuVisible = ref(false);
8993
const contextMenuPosition = ref({ x: 0, y: 0 });
9094
const contextEntry = ref<SftpFileEntry | null>(null);
9195
const { filterHiddenEntries } = useSftpShowHiddenFiles();
9296
const visibleEntries = computed(() => {
9397
const query = search.value.toLowerCase();
94-
return filterHiddenEntries(manager.entries.value)
95-
.filter((entry) => entry.name.toLowerCase().includes(query))
96-
.sort((left, right) => {
97-
if (left.name === "..") return -1;
98-
if (right.name === "..") return 1;
99-
return Number(right.is_dir) - Number(left.is_dir);
100-
});
98+
return sortFileEntries(
99+
filterHiddenEntries(manager.entries.value).filter((entry) => entry.name.toLowerCase().includes(query)),
100+
sortBy.value,
101+
sortDirection.value
102+
);
101103
});
102104
let transferEndpointReady = false;
103105
const selection = useSftpPaneSelection<SftpFileEntry>({ visibleEntries });
@@ -144,6 +146,12 @@ const transferDropBlocked = computed(
144146
activeTransferDragSourceId.value === props.transferEndpoint?.id
145147
);
146148
149+
function changeSort(column: SftpFileSortColumn, direction: "asc" | "desc"): void {
150+
sortBy.value = column;
151+
sortDirection.value = direction;
152+
selection.resetSelectionAnchor();
153+
}
154+
147155
function hideContextMenu(): void {
148156
contextMenuVisible.value = false;
149157
contextEntry.value = null;
@@ -499,6 +507,8 @@ defineExpose({
499507
<SftpPaneFileTable
500508
class="min-h-0 flex-1"
501509
:entries="visibleEntries"
510+
:sort-by="sortBy"
511+
:sort-direction="sortDirection"
502512
:selected-names="selectedEntries.map((entry) => entry.name)"
503513
:highlighted-names="highlightedNames"
504514
:select-all-state="selectAllState"
@@ -507,6 +517,7 @@ defineExpose({
507517
:list-key="manager.currentPath.value"
508518
:refreshing="manager.loading.value && manager.entries.value.length > 0"
509519
show-status-bar
520+
@sort="changeSort"
510521
@select="selectEntry"
511522
@toggle="toggleEntry"
512523
@toggle-all="toggleAllVisible"

‎ui/koko/components/FileManagement/pane/SftpPaneFileTable.vue‎

Lines changed: 59 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
<script setup lang="ts">
2+
import type { SftpFileSortColumn } from "#koko/composables/sftp/file-manager/filePresentation";
23
import type { SftpFileEntry } from "#koko/composables/sftp/useSftpFileManager";
34
import {
45
formatSftpFileSize,
@@ -19,6 +20,8 @@ const props = withDefaults(
1920
compact?: boolean;
2021
listKey?: string;
2122
refreshing?: boolean;
23+
sortBy: SftpFileSortColumn;
24+
sortDirection: "asc" | "desc";
2225
}>(),
2326
{
2427
highlightedNames: () => [],
@@ -32,6 +35,7 @@ const props = withDefaults(
3235
);
3336
3437
const emit = defineEmits<{
38+
sort: [column: SftpFileSortColumn, direction: "asc" | "desc"];
3539
select: [entry: SftpFileEntry, event: MouseEvent];
3640
toggle: [entry: SftpFileEntry, selected: boolean];
3741
toggleAll: [selected: boolean];
@@ -43,6 +47,7 @@ const emit = defineEmits<{
4347
const { t } = useI18n();
4448
const selectedSet = computed(() => new Set(props.selectedNames));
4549
const highlightedSet = computed(() => new Set(props.highlightedNames));
50+
const sortIcon = computed(() => (props.sortDirection === "asc" ? "i-lucide-arrow-up" : "i-lucide-arrow-down"));
4651
const emptyColspan = computed(() => (props.compact ? 3 : 5));
4752
const selectedBytes = computed(() => {
4853
if (!props.selectedNames.length) return 0;
@@ -71,6 +76,16 @@ const sizeColClass = computed(() => (props.compact ? "w-22 px-2.5" : "w-27.5 px-
7176
/** Keep row enter/leave for transfer highlight & delete; same-key refresh does not re-enter rows. */
7277
const rowTransitionName = computed(() => (prefersReducedMotion.value ? "" : "sftp-file-row"));
7378
79+
function requestSort(column: SftpFileSortColumn): void {
80+
const direction = props.sortBy === column && props.sortDirection === "asc" ? "desc" : "asc";
81+
emit("sort", column, direction);
82+
}
83+
84+
function columnSort(column: SftpFileSortColumn): "ascending" | "descending" | "none" {
85+
if (props.sortBy !== column) return "none";
86+
return props.sortDirection === "asc" ? "ascending" : "descending";
87+
}
88+
7489
function fileType(entry: SftpFileEntry): string {
7590
return resolveSftpFileType(entry, {
7691
folder: t("koko.fileManagement.folder"),
@@ -175,26 +190,66 @@ onUnmounted(() => {
175190
</th>
176191
<th
177192
class="h-8.5 min-w-0 border-b border-(--app-border) bg-(--app-panel-bg) px-3.5 py-1.5 text-left text-[11px] font-semibold text-muted"
193+
:aria-sort="columnSort('name')"
178194
>
179-
{{ t("koko.fileManagement.name") }}
195+
<UButton
196+
size="xs"
197+
color="neutral"
198+
variant="link"
199+
:trailing-icon="sortBy === 'name' ? sortIcon : undefined"
200+
class="gap-1 p-0 text-[11px] font-semibold text-muted"
201+
@click="requestSort('name')"
202+
>
203+
{{ t("koko.fileManagement.name") }}
204+
</UButton>
180205
</th>
181206
<th
182207
v-if="!compact"
183208
class="hidden h-8.5 border-b border-(--app-border) bg-(--app-panel-bg) px-3.5 py-1.5 text-right text-[11px] font-semibold text-muted md:table-cell"
209+
:aria-sort="columnSort('mod_time')"
184210
>
185-
{{ t("koko.fileManagement.modifiedTime") }}
211+
<UButton
212+
size="xs"
213+
color="neutral"
214+
variant="link"
215+
:trailing-icon="sortBy === 'mod_time' ? sortIcon : undefined"
216+
class="gap-1 p-0 text-[11px] font-semibold text-muted"
217+
@click="requestSort('mod_time')"
218+
>
219+
{{ t("koko.fileManagement.modifiedTime") }}
220+
</UButton>
186221
</th>
187222
<th
188223
class="h-8.5 border-b border-(--app-border) bg-(--app-panel-bg) py-1.5 text-right text-[11px] font-semibold text-muted"
189224
:class="sizeColClass"
225+
:aria-sort="columnSort('size')"
190226
>
191-
{{ t("koko.fileManagement.size") }}
227+
<UButton
228+
size="xs"
229+
color="neutral"
230+
variant="link"
231+
:trailing-icon="sortBy === 'size' ? sortIcon : undefined"
232+
class="gap-1 p-0 text-[11px] font-semibold text-muted"
233+
@click="requestSort('size')"
234+
>
235+
{{ t("koko.fileManagement.size") }}
236+
</UButton>
192237
</th>
193238
<th
194239
v-if="!compact"
195240
class="h-8.5 border-b border-(--app-border) bg-(--app-panel-bg) px-3.5 py-1.5 text-left text-[11px] font-semibold text-muted"
241+
:aria-sort="columnSort('type')"
196242
>
197-
{{ t("koko.fileManagement.type") }}
243+
<UButton
244+
size="xs"
245+
color="neutral"
246+
variant="link"
247+
:trailing-icon="sortBy === 'type' ? sortIcon : undefined"
248+
class="gap-1 p-0 text-[11px] font-semibold text-muted"
249+
@click="requestSort('type')"
250+
>
251+
{{ t("koko.fileManagement.type") }}
252+
</UButton>
198253
</th>
199254
</tr>
200255
</thead>

‎ui/koko/composables/sftp/file-manager/filePresentation.ts‎

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,48 @@
11
import type { SftpFileEntry } from "#koko/composables/sftp/useSftpFileManager";
22

3+
export type SftpFileSortColumn = "name" | "mod_time" | "size" | "type";
4+
35
const sizeUnits = ["B", "KB", "MB", "GB", "TB"];
6+
const entryNameCollator = new Intl.Collator(undefined, { numeric: true, sensitivity: "base" });
7+
8+
function fileModifiedTimestamp(value: string): number {
9+
if (!value.trim()) return NaN;
10+
const timestamp = Number(value);
11+
return Number.isFinite(timestamp)
12+
? timestamp < 1_000_000_000_000
13+
? timestamp * 1000
14+
: timestamp
15+
: Date.parse(value);
16+
}
17+
18+
export function sortFileEntries(
19+
entries: SftpFileEntry[],
20+
column: SftpFileSortColumn,
21+
order: "asc" | "desc"
22+
): SftpFileEntry[] {
23+
const direction = order === "asc" ? 1 : -1;
24+
return [...entries].sort((left, right) => {
25+
if (left.name === ".." || right.name === "..") return Number(right.name === "..") - Number(left.name === "..");
26+
if (left.is_dir !== right.is_dir) return left.is_dir ? -1 : 1;
27+
const nameOrder = entryNameCollator.compare(left.name, right.name);
28+
if (column === "name") return nameOrder * direction;
29+
if (column === "size" && left.is_dir) return nameOrder;
30+
if (column === "type") {
31+
const leftType = resolveSftpFileType(left, { folder: "", file: "" });
32+
const rightType = resolveSftpFileType(right, { folder: "", file: "" });
33+
if (Boolean(leftType) !== Boolean(rightType)) return leftType ? -1 : 1;
34+
return entryNameCollator.compare(leftType, rightType) * direction || nameOrder;
35+
}
36+
const leftValue =
37+
column === "size" ? (left.size.trim() ? Number(left.size) : NaN) : fileModifiedTimestamp(left.mod_time);
38+
const rightValue =
39+
column === "size" ? (right.size.trim() ? Number(right.size) : NaN) : fileModifiedTimestamp(right.mod_time);
40+
const leftMissing = !Number.isFinite(leftValue);
41+
const rightMissing = !Number.isFinite(rightValue);
42+
if (leftMissing !== rightMissing) return leftMissing ? 1 : -1;
43+
return (leftMissing ? 0 : (leftValue - rightValue) * direction) || nameOrder;
44+
});
45+
}
446

547
export function formatSftpFileSize(value: string): string {
648
const bytes = Number(value);

‎ui/koko/composables/sftp/file-manager/useSftpPaneSelection.ts‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,10 @@ export function useSftpPaneSelection<T extends FilePaneEntry>(options: UseSftpPa
3737
selectionRevision.value += 1;
3838
}
3939

40+
function resetSelectionAnchor() {
41+
lastClickedIndex.value = -1;
42+
}
43+
4044
function isSelected(entry: T) {
4145
return selectedEntries.value.some((item) => item.name === entry.name);
4246
}
@@ -145,6 +149,7 @@ export function useSftpPaneSelection<T extends FilePaneEntry>(options: UseSftpPa
145149
selectAllState,
146150
clearSelection,
147151
updateSelection,
152+
resetSelectionAnchor,
148153
clearTransferredSelection,
149154
isSelected,
150155
moveSelection,

0 commit comments

Comments
 (0)