mirror of
https://github.com/45Drives/cockpit-navigator.git
synced 2025-09-26 11:18:42 +02:00
281 lines
8.3 KiB
Vue
281 lines
8.3 KiB
Vue
<template>
|
|
<template v-for="entry, index in visibleEntries" :key="entry.path">
|
|
<DirectoryEntry
|
|
:show="true"
|
|
:entry="entry"
|
|
:inheritedSortCallback="sortCallback"
|
|
:searchFilterRegExp="searchFilterRegExp"
|
|
@cd="(...args) => $emit('cd', ...args)"
|
|
@edit="(...args) => $emit('edit', ...args)"
|
|
@toggleSelected="modifiers => selection.toggle(entry, index, modifiers)"
|
|
@deselectAll="selection.deselectAllBackward()"
|
|
@sortEntries="sortEntries"
|
|
@updateStats="emitStats"
|
|
@startProcessing="(...args) => $emit('startProcessing', ...args)"
|
|
@stopProcessing="(...args) => $emit('stopProcessing', ...args)"
|
|
ref="entryRefs"
|
|
:level="level"
|
|
:neighboursSelected="{ above: visibleEntries[index - 1]?.selected ?? false, below: visibleEntries[index + 1]?.selected ?? false }"
|
|
/>
|
|
</template>
|
|
<tr
|
|
v-if="show && visibleEntries.length === 0"
|
|
>
|
|
<td
|
|
:colspan="Object.values(settings?.directoryView?.cols ?? {}).reduce((sum, current) => current ? sum + 1 : sum, 1) ?? 100"
|
|
class="!pl-1 text-muted text-sm"
|
|
>
|
|
<div class="inline-block" :style="{ width: `${24 * level}px` }"></div>
|
|
<div class="inline-block">No entries.</div>
|
|
</td>
|
|
</tr>
|
|
</template>
|
|
|
|
<script>
|
|
import { ref, reactive, computed, inject, watch, onBeforeUnmount, onMounted } from 'vue';
|
|
import { useSpawn, errorString, errorStringHTML, canonicalPath } from '@45drives/cockpit-helpers';
|
|
import { notificationsInjectionKey, settingsInjectionKey } from '../keys';
|
|
import DirectoryEntry from './DirectoryEntry.vue';
|
|
import getDirListing from '../functions/getDirListing';
|
|
import getDirEntryObjects from '../functions/getDirEntryObjects';
|
|
import { RECORD_SEPARATOR, UNIT_SEPARATOR } from '../constants';
|
|
|
|
export default {
|
|
name: 'DirectoryEntryList',
|
|
props: {
|
|
path: String,
|
|
searchFilterRegExp: RegExp,
|
|
show: {
|
|
type: Boolean,
|
|
required: false,
|
|
default: true,
|
|
},
|
|
sortCallback: {
|
|
type: Function,
|
|
required: false,
|
|
default: (() => 0),
|
|
},
|
|
level: Number,
|
|
},
|
|
setup(props, { emit }) {
|
|
const settings = inject(settingsInjectionKey);
|
|
const entries = ref([]);
|
|
const visibleEntries = ref([]);
|
|
const notifications = inject(notificationsInjectionKey);
|
|
const entryRefs = ref([]);
|
|
const sortCallbackComputed = computed(() => {
|
|
return (a, b) => {
|
|
if (settings.directoryView?.separateDirs) {
|
|
const checkA = a.type === 'symbolic link' ? (a.target?.type ?? null) : a.type;
|
|
const checkB = b.type === 'symbolic link' ? (b.target?.type ?? null) : b.type;
|
|
if (checkA === null || checkB === null)
|
|
return 0;
|
|
if (checkA === 'directory' && checkB !== 'directory')
|
|
return -1;
|
|
else if (checkA !== 'directory' && checkB === 'directory')
|
|
return 1;
|
|
}
|
|
return props.sortCallback(a, b);
|
|
}
|
|
});
|
|
const selection = reactive({
|
|
lastSelectedInd: null,
|
|
toggle: (entry, index, modifiers) => {
|
|
const entrySelectedValue = entry.selected;
|
|
if (!modifiers.ctrlKey) {
|
|
const tmpLastSelectedInd = selection.lastSelectedInd;
|
|
selection.deselectAllBackward();
|
|
selection.lastSelectedInd = tmpLastSelectedInd;
|
|
}
|
|
if (modifiers.shiftKey && selection.lastSelectedInd !== null) {
|
|
let [startInd, endInd] = [selection.lastSelectedInd, index];
|
|
if (endInd < startInd)
|
|
[startInd, endInd] = [endInd, startInd];
|
|
visibleEntries.value
|
|
.slice(startInd, endInd + 1)
|
|
.map(entry => entry.selected = true);
|
|
} else {
|
|
entry.selected = modifiers.ctrlKey ? !entrySelectedValue : true;
|
|
if (entry.selected)
|
|
selection.lastSelectedInd = index;
|
|
else
|
|
selection.lastSelectedInd = null;
|
|
}
|
|
},
|
|
getSelected: () => [
|
|
...visibleEntries.value.filter(entry => entry.selected),
|
|
...entryRefs.value
|
|
.filter(entryRef => entryRef.showEntries)
|
|
.map(entryRef => entryRef.getSelected())
|
|
.flat(1),
|
|
],
|
|
clear: () => {
|
|
entries.value.map(entry => entry.selected = false);
|
|
},
|
|
selectAll: () => {
|
|
visibleEntries.value
|
|
.map(entry => entry.selected = true);
|
|
entryRefs.value
|
|
.map(entryRef => entryRef.selectAll());
|
|
},
|
|
deselectAllBackward: () => {
|
|
if (props.level > 0)
|
|
emit('deselectAll');
|
|
else
|
|
selection.deselectAllForward();
|
|
},
|
|
deselectAllForward: () => {
|
|
selection.clear();
|
|
selection.lastSelectedInd = null;
|
|
entryRefs.value
|
|
.map(entryRef => entryRef.deselectAllForward());
|
|
},
|
|
});
|
|
const processingHandler = {
|
|
count: 0,
|
|
start: () => {
|
|
emit('startProcessing');
|
|
processingHandler.count++;
|
|
},
|
|
stop: () => {
|
|
if (processingHandler.count > 0) {
|
|
emit('stopProcessing');
|
|
processingHandler.count--;
|
|
}
|
|
},
|
|
resolveDangling: () => {
|
|
for (; processingHandler.count > 0; processingHandler.count--)
|
|
emit('stopProcessing');
|
|
}
|
|
}
|
|
|
|
const getEntries = async () => {
|
|
if (!props.path) {
|
|
return;
|
|
}
|
|
selection.lastSelectedInd = null;
|
|
processingHandler.start();
|
|
const processLinks = (linkTargets) => {
|
|
if (linkTargets.length === 0)
|
|
return null;
|
|
const callback = state => state.stdout
|
|
.trim()
|
|
.split(RECORD_SEPARATOR)
|
|
.filter(record => record)
|
|
.map((record, index) => {
|
|
if (record.includes(UNIT_SEPARATOR)) {
|
|
const [type, mode] = record.split(UNIT_SEPARATOR);
|
|
linkTargets[index].type = type;
|
|
linkTargets[index].mode = mode;
|
|
linkTargets[index].broken = false;
|
|
} else { // error
|
|
linkTargets[index].broken = true;
|
|
}
|
|
});
|
|
return new Promise((resolve, reject) =>
|
|
useSpawn(['stat', `--printf=%F${UNIT_SEPARATOR}%f${RECORD_SEPARATOR}`, ...linkTargets.map(target => target.path)], { superuser: 'try', err: 'out' }).promise()
|
|
.then(callback)
|
|
.catch(callback)
|
|
.finally(resolve)
|
|
)
|
|
}
|
|
try {
|
|
const cwd = props.path;
|
|
const procs = [];
|
|
procs.push(...entryRefs.value.filter(entryRef => entryRef.showEntries).map(entryRef => entryRef.getEntries()));
|
|
const entryNames = await getDirListing(cwd, (message) => notifications.value.constructNotification("Failed to parse file name", message, 'error'));
|
|
const tmpEntries = (
|
|
await getDirEntryObjects(
|
|
entryNames,
|
|
cwd,
|
|
(message) => notifications.value.constructNotification("Failed to parse file name", message, 'error')
|
|
)
|
|
);
|
|
procs.push(processLinks(tmpEntries.filter(entry => entry.type === 'symbolic link').map(entry => entry.target)));
|
|
processingHandler.start();
|
|
return Promise.all(procs)
|
|
.then(() => {
|
|
if (props.path !== cwd)
|
|
return;
|
|
entries.value = [...tmpEntries];
|
|
sortEntries();
|
|
emitStats();
|
|
})
|
|
.finally(() => processingHandler.stop());
|
|
} catch (error) {
|
|
entries.value = [];
|
|
notifications.value.constructNotification("Error getting directory entries", errorStringHTML(error), 'error');
|
|
emit('cancelShowEntries');
|
|
} finally {
|
|
processingHandler.stop();
|
|
}
|
|
}
|
|
|
|
const emitStats = () => {
|
|
emit('updateStats', entries.value.reduce((stats, entry) => {
|
|
if (entry.type === 'directory' || (entry.type === 'symbolic link' && entry.target?.type === 'directory'))
|
|
stats.dirs++;
|
|
else
|
|
stats.files++;
|
|
return stats;
|
|
}, { files: 0, dirs: 0 }));
|
|
}
|
|
|
|
const sortEntries = () => {
|
|
if (processingHandler.count) {
|
|
setTimeout(sortEntries, 100); // poll until nothing processing
|
|
} else {
|
|
processingHandler.start();
|
|
entries.value = [...entries.value].sort(sortCallbackComputed.value);
|
|
processingHandler.stop();
|
|
}
|
|
}
|
|
|
|
const entryFilterCallback = (entry) =>
|
|
(!/^\./.test(entry.name) || settings?.directoryView?.showHidden)
|
|
&& (props.searchFilterRegExp?.test(entry.name) ?? true);
|
|
|
|
onBeforeUnmount(() => {
|
|
processingHandler.resolveDangling();
|
|
});
|
|
|
|
watch(() => props.sortCallback, sortEntries);
|
|
watch(() => settings.directoryView?.separateDirs, sortEntries);
|
|
|
|
watch([() => entries.value, () => settings?.directoryView?.showHidden, () => props.searchFilterRegExp], () => {
|
|
visibleEntries.value = entries.value.filter(entryFilterCallback);
|
|
})
|
|
|
|
watch(() => props.path, (current, old) => {
|
|
if (current === old)
|
|
return;
|
|
getEntries();
|
|
}, { immediate: true });
|
|
|
|
return {
|
|
settings,
|
|
entries,
|
|
visibleEntries,
|
|
entryRefs,
|
|
selection,
|
|
getEntries,
|
|
emitStats,
|
|
sortEntries,
|
|
entryFilterCallback,
|
|
}
|
|
},
|
|
components: {
|
|
DirectoryEntry,
|
|
},
|
|
emits: [
|
|
'cd',
|
|
'edit',
|
|
'updateStats',
|
|
'startProcessing',
|
|
'stopProcessing',
|
|
'cancelShowEntries',
|
|
'deselectAll',
|
|
]
|
|
}
|
|
</script>
|