mirror of
https://github.com/45Drives/cockpit-navigator.git
synced 2025-07-28 16:14:25 +02:00
add recursive directory tree listing and start search
This commit is contained in:
parent
2f6ae732e5
commit
8850189771
@ -1,17 +1,28 @@
|
|||||||
<template>
|
<template>
|
||||||
<tr v-if="listView" @dblclick="doubleClickCallback" class="hover:!bg-red-600/10">
|
<tr
|
||||||
<td class="w-6 !py-0 !px-1">
|
v-show="!/^\./.test(entry.name) || settings?.directoryView?.showHidden"
|
||||||
<div class="relative">
|
v-if="settings.directoryView?.view === 'list'"
|
||||||
|
@dblclick="doubleClickCallback"
|
||||||
|
class="hover:!bg-red-600/10"
|
||||||
|
>
|
||||||
|
<td class="flex items-center gap-1 !pl-1">
|
||||||
|
<div :style="{ width: `${24 * level}px` }"></div>
|
||||||
|
<div class="relative w-6">
|
||||||
<component :is="icon" class="size-icon icon-default" />
|
<component :is="icon" class="size-icon icon-default" />
|
||||||
<LinkIcon v-if="entry.type === 'link'" class="w-2 h-2 absolute right-0 bottom-0 text-default" />
|
<LinkIcon v-if="entry.type === 'link'" class="w-2 h-2 absolute right-0 bottom-0 text-default" />
|
||||||
</div>
|
</div>
|
||||||
</td>
|
<button v-if="directoryLike" @click.stop="showEntries = !showEntries">
|
||||||
<td class="!pl-1">
|
<ChevronDownIcon v-if="!showEntries" class="size-icon icon-default" />
|
||||||
{{ entry.name }}
|
<ChevronUpIcon v-else class="size-icon icon-default" />
|
||||||
|
</button>
|
||||||
|
<div>{{ entry.name }}</div>
|
||||||
<div v-if="entry.type === 'link'" class="inline-flex gap-1 items-center">
|
<div v-if="entry.type === 'link'" class="inline-flex gap-1 items-center">
|
||||||
<div class="inline relative">
|
<div class="inline relative">
|
||||||
<ArrowNarrowRightIcon class="text-default size-icon-sm inline" />
|
<ArrowNarrowRightIcon class="text-default size-icon-sm inline" />
|
||||||
<XIcon v-if="entry.target?.broken" class="icon-danger size-icon-sm absolute inset-x-0 bottom-0" />
|
<XIcon
|
||||||
|
v-if="entry.target?.broken"
|
||||||
|
class="icon-danger size-icon-sm absolute inset-x-0 bottom-0"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>{{ entry.target?.rawPath ?? '' }}</div>
|
<div>{{ entry.target?.rawPath ?? '' }}</div>
|
||||||
</div>
|
</div>
|
||||||
@ -24,36 +35,58 @@
|
|||||||
<td v-if="settings?.directoryView?.cols?.mtime">{{ entry.mtime?.toLocaleString() ?? '-' }}</td>
|
<td v-if="settings?.directoryView?.cols?.mtime">{{ entry.mtime?.toLocaleString() ?? '-' }}</td>
|
||||||
<td v-if="settings?.directoryView?.cols?.atime">{{ entry.atime?.toLocaleString() ?? '-' }}</td>
|
<td v-if="settings?.directoryView?.cols?.atime">{{ entry.atime?.toLocaleString() ?? '-' }}</td>
|
||||||
</tr>
|
</tr>
|
||||||
<div v-else @dblclick="doubleClickCallback" class="flex flex-col gap-content items-center">
|
<div
|
||||||
<div class="relative">
|
v-else
|
||||||
<component
|
v-show="!/^\./.test(entry.name) || settings?.directoryView?.showHidden"
|
||||||
:is="icon"
|
@dblclick="doubleClickCallback"
|
||||||
:class="[settings.directoryView?.view === 'list' ? 'size-icon' : 'size-icon-xl', 'icon-default']"
|
class="flex flex-col items-center w-20 overflow-hidden"
|
||||||
/>
|
>
|
||||||
<LinkIcon
|
<div class="relative w-20">
|
||||||
v-if="entry.type === 'link'"
|
<component :is="icon" class="icon-default w-20 h-auto" />
|
||||||
class="size-icon-sm absolute right-0 bottom-0 text-gray-100 dark:text-gray-900"
|
<div :class="[directoryLike ? 'right-3 bottom-5' : 'right-5 bottom-3', 'inline absolute']" :title="`-> ${entry.target?.rawPath ?? '?'}`">
|
||||||
/>
|
<LinkIcon v-if="entry.type === 'link'" :class="[entry.target?.broken ? 'text-red-300 dark:text-red-800' : 'text-gray-100 dark:text-gray-900','w-4 h-auto']" />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div>{{ entry.name }}</div>
|
<div class="text-center w-full" style="overflow-wrap: break-word;">{{ entry.name }}</div>
|
||||||
</div>
|
</div>
|
||||||
|
<component
|
||||||
|
:is="DirectoryEntryList"
|
||||||
|
v-if="directoryLike && showEntries"
|
||||||
|
:path="entry.path"
|
||||||
|
:isChild="true"
|
||||||
|
:inheritedSortCallback="inheritedSortCallback"
|
||||||
|
@cd="(...args) => $emit('cd', ...args)"
|
||||||
|
@edit="(...args) => $emit('edit', ...args)"
|
||||||
|
@startProcessing="(...args) => $emit('startProcessing', ...args)"
|
||||||
|
@stopProcessing="(...args) => $emit('stopProcessing', ...args)"
|
||||||
|
ref="directoryViewRef"
|
||||||
|
:level="level + 1"
|
||||||
|
/>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
import { ref, inject, watch } from 'vue';
|
import { ref, inject, watch } from 'vue';
|
||||||
import { DocumentIcon, FolderIcon, LinkIcon, DocumentRemoveIcon, ArrowNarrowRightIcon, XIcon } from '@heroicons/vue/solid';
|
import { DocumentIcon, FolderIcon, LinkIcon, DocumentRemoveIcon, ArrowNarrowRightIcon, XIcon, ChevronDownIcon, ChevronUpIcon } from '@heroicons/vue/solid';
|
||||||
import { settingsInjectionKey } from '../keys';
|
import { settingsInjectionKey } from '../keys';
|
||||||
|
import DirectoryEntryList from './DirectoryEntryList.vue';
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
|
name: 'DirectoryEntry',
|
||||||
props: {
|
props: {
|
||||||
entry: Object,
|
entry: Object,
|
||||||
listView: Boolean,
|
inheritedSortCallback: {
|
||||||
|
type: Function,
|
||||||
|
required: false,
|
||||||
|
default: null,
|
||||||
|
},
|
||||||
|
level: Number,
|
||||||
},
|
},
|
||||||
setup(props, { emit }) {
|
setup(props, { emit }) {
|
||||||
const settings = inject(settingsInjectionKey);
|
const settings = inject(settingsInjectionKey);
|
||||||
const icon = ref(FolderIcon);
|
const icon = ref(FolderIcon);
|
||||||
const directoryLike = ref(false);
|
const directoryLike = ref(false);
|
||||||
const brokenLink = ref(false);
|
const showEntries = ref(false);
|
||||||
|
const directoryViewRef = ref();
|
||||||
|
|
||||||
const doubleClickCallback = () => {
|
const doubleClickCallback = () => {
|
||||||
if (directoryLike.value) {
|
if (directoryLike.value) {
|
||||||
@ -63,6 +96,10 @@ export default {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const getEntries = () => {
|
||||||
|
return directoryViewRef.value?.getEntries?.();
|
||||||
|
}
|
||||||
|
|
||||||
watch(props.entry, () => {
|
watch(props.entry, () => {
|
||||||
if (props.entry.type === 'directory' || (props.entry.type === 'link' && props.entry.target?.type === 'directory')) {
|
if (props.entry.type === 'directory' || (props.entry.type === 'link' && props.entry.target?.type === 'directory')) {
|
||||||
icon.value = FolderIcon;
|
icon.value = FolderIcon;
|
||||||
@ -77,8 +114,11 @@ export default {
|
|||||||
settings,
|
settings,
|
||||||
icon,
|
icon,
|
||||||
directoryLike,
|
directoryLike,
|
||||||
brokenLink,
|
showEntries,
|
||||||
|
directoryViewRef,
|
||||||
doubleClickCallback,
|
doubleClickCallback,
|
||||||
|
getEntries,
|
||||||
|
DirectoryEntryList,
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
components: {
|
components: {
|
||||||
@ -88,10 +128,15 @@ export default {
|
|||||||
DocumentRemoveIcon,
|
DocumentRemoveIcon,
|
||||||
ArrowNarrowRightIcon,
|
ArrowNarrowRightIcon,
|
||||||
XIcon,
|
XIcon,
|
||||||
|
DirectoryEntryList,
|
||||||
|
ChevronDownIcon,
|
||||||
|
ChevronUpIcon,
|
||||||
},
|
},
|
||||||
emits: [
|
emits: [
|
||||||
'cd',
|
'cd',
|
||||||
'edit',
|
'edit',
|
||||||
|
'startProcessing',
|
||||||
|
'stopProcessing',
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
272
navigator-vue/src/components/DirectoryEntryList.vue
Normal file
272
navigator-vue/src/components/DirectoryEntryList.vue
Normal file
@ -0,0 +1,272 @@
|
|||||||
|
<template>
|
||||||
|
<DirectoryEntry
|
||||||
|
v-for="entry, index in entries"
|
||||||
|
:key="entry.path"
|
||||||
|
:entry="entry"
|
||||||
|
:inheritedSortCallback="sortCallback"
|
||||||
|
@cd="(...args) => $emit('cd', ...args)"
|
||||||
|
@edit="(...args) => $emit('edit', ...args)"
|
||||||
|
@sortEntries="sortEntries"
|
||||||
|
@updateStats="emitStats"
|
||||||
|
@startProcessing="(...args) => $emit('startProcessing', ...args)"
|
||||||
|
@stopProcessing="(...args) => $emit('stopProcessing', ...args)"
|
||||||
|
ref="entryRefs"
|
||||||
|
:level="level"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import { ref, reactive, computed, inject, watch } from 'vue';
|
||||||
|
import { useSpawn, errorString, errorStringHTML, canonicalPath } from '@45drives/cockpit-helpers';
|
||||||
|
import { notificationsInjectionKey, settingsInjectionKey } from '../keys';
|
||||||
|
import DirectoryEntry from './DirectoryEntry.vue';
|
||||||
|
|
||||||
|
export default {
|
||||||
|
name: 'DirectoryEntryList',
|
||||||
|
props: {
|
||||||
|
path: String,
|
||||||
|
sortCallback: {
|
||||||
|
type: Function,
|
||||||
|
required: false,
|
||||||
|
default: (() => 0),
|
||||||
|
},
|
||||||
|
level: Number,
|
||||||
|
},
|
||||||
|
setup(props, { emit }) {
|
||||||
|
const settings = inject(settingsInjectionKey);
|
||||||
|
const entries = ref([]);
|
||||||
|
const notifications = inject(notificationsInjectionKey);
|
||||||
|
const entryRefs = ref([]);
|
||||||
|
const sortCallbackComputed = computed(() => {
|
||||||
|
return (a, b) => {
|
||||||
|
if (settings.directoryView?.separateDirs) {
|
||||||
|
const checkA = a.type === 'link' ? (a.target?.type ?? null) : a.type;
|
||||||
|
const checkB = b.type === '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 getAsyncEntryStats = (cwd, entry, modeStr, path, linkTargetRaw) => {
|
||||||
|
const procs = [];
|
||||||
|
Object.assign(entry, {
|
||||||
|
permissions: {
|
||||||
|
owner: {
|
||||||
|
read: modeStr[1] !== '-',
|
||||||
|
write: modeStr[2] !== '-',
|
||||||
|
execute: modeStr[3] !== '-',
|
||||||
|
},
|
||||||
|
group: {
|
||||||
|
read: modeStr[4] !== '-',
|
||||||
|
write: modeStr[5] !== '-',
|
||||||
|
execute: modeStr[6] !== '-',
|
||||||
|
},
|
||||||
|
other: {
|
||||||
|
read: modeStr[7] !== '-',
|
||||||
|
write: modeStr[8] !== '-',
|
||||||
|
execute: modeStr[9] !== '-',
|
||||||
|
},
|
||||||
|
acl: modeStr[10] === '+' ? {} : null,
|
||||||
|
}
|
||||||
|
});
|
||||||
|
switch (modeStr[0]) {
|
||||||
|
case 'd':
|
||||||
|
entry.type = 'directory';
|
||||||
|
break;
|
||||||
|
case '-':
|
||||||
|
entry.type = 'file';
|
||||||
|
break;
|
||||||
|
case 'p':
|
||||||
|
entry.type = 'pipe';
|
||||||
|
break;
|
||||||
|
case 'l':
|
||||||
|
entry.type = 'link';
|
||||||
|
if (linkTargetRaw) {
|
||||||
|
entry.target = {
|
||||||
|
rawPath: linkTargetRaw,
|
||||||
|
path: canonicalPath(linkTargetRaw.replace(/^(?!=\/)/, cwd + '/')),
|
||||||
|
};
|
||||||
|
emit('startProcessing');
|
||||||
|
procs.push(useSpawn(['stat', '-c', '%A', entry.target.path]).promise()
|
||||||
|
.then(state => {
|
||||||
|
getAsyncEntryStats(cwd, entry.target, state.stdout.trim());
|
||||||
|
entry.target.broken = false;
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
entry.target.broken = true;
|
||||||
|
})
|
||||||
|
.finally(() => emit('stopProcessing'))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case 's':
|
||||||
|
entry.type = 'socket';
|
||||||
|
break;
|
||||||
|
case 'c':
|
||||||
|
entry.type = 'character';
|
||||||
|
break;
|
||||||
|
case 'b':
|
||||||
|
entry.type = 'block';
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
entry.type = 'unk';
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if (entry.permissions.acl && path) {
|
||||||
|
emit('startProcessing');
|
||||||
|
procs.push(useSpawn(['getfacl', '--omit-header', '--no-effective', path], { superuser: 'try' }).promise()
|
||||||
|
.then(state => {
|
||||||
|
entry.permissions.acl = state.stdout
|
||||||
|
.split('\n')
|
||||||
|
.filter(line => line && !/^\s*(?:#|$)/.test(line))
|
||||||
|
.reduce((acl, line) => {
|
||||||
|
const match = line.match(/^([^:]*):([^:]+)?:(.*)$/).slice(1);
|
||||||
|
acl[match[0]] = acl[match[0]] ?? {};
|
||||||
|
acl[match[0]][match[1] ?? '*'] = {
|
||||||
|
r: match[2][0] !== '-',
|
||||||
|
w: match[2][1] !== '-',
|
||||||
|
x: match[2][2] !== '-',
|
||||||
|
}
|
||||||
|
return acl;
|
||||||
|
}, {});
|
||||||
|
})
|
||||||
|
.catch(state => {
|
||||||
|
console.error(`failed to get ACL for ${path}:`, errorString(state));
|
||||||
|
})
|
||||||
|
.finally(() => emit('stopProcessing'))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (path) {
|
||||||
|
emit('startProcessing');
|
||||||
|
procs.push(useSpawn(['stat', '-c', '%W:%Y:%X', path], { superuser: 'try' }).promise() // birth:modification:access
|
||||||
|
.then(state => {
|
||||||
|
const [ctimeStr, mtimeStr, atimeStr] = state.stdout.trim().split(':');
|
||||||
|
Object.assign(entry, {
|
||||||
|
ctime: new Date(parseInt(ctimeStr) * 1000),
|
||||||
|
mtime: new Date(parseInt(mtimeStr) * 1000),
|
||||||
|
atime: new Date(parseInt(atimeStr) * 1000),
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.catch(state =>
|
||||||
|
notifications.value.constructNotification(`Failed to get stats for ${path}`, errorStringHTML(state), 'error')
|
||||||
|
)
|
||||||
|
.finally(() => emit('stopProcessing'))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return Promise.all(procs);
|
||||||
|
}
|
||||||
|
|
||||||
|
const getEntries = async () => {
|
||||||
|
emit('startProcessing');
|
||||||
|
try {
|
||||||
|
const cwd = props.path;
|
||||||
|
const procs = [];
|
||||||
|
procs.push(entryRefs.value.map(entryRef => entryRef.getEntries()));
|
||||||
|
let lsOutput;
|
||||||
|
try {
|
||||||
|
lsOutput = (await useSpawn(['ls', '-al', '--color=never', '--time-style=full-iso', '--quote-name', '--dereference-command-line-symlink-to-dir', cwd], { superuser: 'try' }).promise()).stdout
|
||||||
|
} catch (state) {
|
||||||
|
if (state.exit_code === 1)
|
||||||
|
lsOutput = state.stdout; // non-fatal ls error
|
||||||
|
else
|
||||||
|
throw new Error(state.stderr);
|
||||||
|
}
|
||||||
|
entries.value = lsOutput
|
||||||
|
.split('\n')
|
||||||
|
.filter(line => !/^(?:\s*$|total)/.test(line)) // remove empty lines
|
||||||
|
.map(record => {
|
||||||
|
try {
|
||||||
|
if (cwd !== props.path)
|
||||||
|
return null;
|
||||||
|
const entry = reactive({});
|
||||||
|
const fields = record.match(/^([a-z-]+\+?)\s+(\d+)\s+(\S+)\s+(\S+)\s+(\d+(?:,\s+\d+)?)\s+([^"]+)"([^"]+)"(?:\s+->\s+"([^"]+)")?/)?.slice(1);
|
||||||
|
if (!fields) {
|
||||||
|
console.error('regex failed to match on', record);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
entry.name = fields[6];
|
||||||
|
if (entry.name === '.' || entry.name === '..')
|
||||||
|
return null;
|
||||||
|
entry.path = canonicalPath(cwd + `/${entry.name}`);
|
||||||
|
entry.modeStr = fields[0];
|
||||||
|
entry.hardlinkCount = parseInt(fields[1]);
|
||||||
|
entry.owner = fields[2];
|
||||||
|
entry.group = fields[3];
|
||||||
|
if (/,/.test(fields[4])) {
|
||||||
|
[entry.major, entry.minor] = fields[4].split(/,\s+/);
|
||||||
|
entry.size = null;
|
||||||
|
} else {
|
||||||
|
entry.size = parseInt(fields[4]);
|
||||||
|
entry.sizeHuman = cockpit.format_bytes(entry.size, 1000).replace(/(?<!B)$/, ' B');
|
||||||
|
entry.major = entry.minor = null;
|
||||||
|
}
|
||||||
|
procs.push(getAsyncEntryStats(cwd, entry, entry.modeStr, entry.path, fields[7]));
|
||||||
|
return entry;
|
||||||
|
} catch (error) {
|
||||||
|
notifications.value.constructNotification(`Error while gathering info for ${entry.path ?? record}`, errorStringHTML(error), 'error');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.filter(entry => entry !== null)
|
||||||
|
?? [];
|
||||||
|
return Promise.all(procs).then(() => {
|
||||||
|
emitStats();
|
||||||
|
sortEntries();
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
entries.value = [];
|
||||||
|
notifications.value.constructNotification("Error getting directory entries", errorStringHTML(error), 'error');
|
||||||
|
} finally {
|
||||||
|
emit('stopProcessing');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const emitStats = () => {
|
||||||
|
emit('updateStats', entries.value.reduce((stats, entry) => {
|
||||||
|
if (entry.type === 'directory' || (entry.type === 'link' && entry.target?.type === 'directory'))
|
||||||
|
stats.dirs++;
|
||||||
|
else
|
||||||
|
stats.files++;
|
||||||
|
return stats;
|
||||||
|
}, { files: 0, dirs: 0 }));
|
||||||
|
}
|
||||||
|
|
||||||
|
const sortEntries = () => {
|
||||||
|
emit('startProcessing');
|
||||||
|
entries.value.sort(sortCallbackComputed.value);
|
||||||
|
emit('stopProcessing');
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(() => props.sortCallback, sortEntries);
|
||||||
|
watch(entries, sortEntries);
|
||||||
|
watch(() => settings.directoryView?.separateDirs, sortEntries);
|
||||||
|
|
||||||
|
watch(() => props.path, getEntries, { immediate: true });
|
||||||
|
|
||||||
|
return {
|
||||||
|
settings,
|
||||||
|
entries,
|
||||||
|
entryRefs,
|
||||||
|
getEntries,
|
||||||
|
emitStats,
|
||||||
|
sortEntries,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
components: {
|
||||||
|
DirectoryEntry,
|
||||||
|
},
|
||||||
|
emits: [
|
||||||
|
'cd',
|
||||||
|
'edit',
|
||||||
|
'updateStats',
|
||||||
|
'startProcessing',
|
||||||
|
'stopProcessing',
|
||||||
|
]
|
||||||
|
}
|
||||||
|
</script>
|
@ -1,115 +1,111 @@
|
|||||||
<template>
|
<template>
|
||||||
<Table
|
<div class="h-full">
|
||||||
v-if="settings.directoryView?.view === 'list'"
|
<Table
|
||||||
emptyText="No entries."
|
v-if="settings.directoryView?.view === 'list'"
|
||||||
noHeader
|
emptyText="No entries."
|
||||||
stickyHeaders
|
noHeader
|
||||||
noShrink
|
stickyHeaders
|
||||||
noShrinkHeight="h-full"
|
noShrink
|
||||||
class="rounded-lg"
|
noShrinkHeight="h-full"
|
||||||
>
|
>
|
||||||
<template #thead>
|
<template #thead>
|
||||||
<tr>
|
<tr v-if="!isChild">
|
||||||
<th class="w-6 !p-0">
|
<th class="!pl-1">
|
||||||
<div class="flex items-center justify-center">
|
<div class="flex flex-row flex-nowrap gap-1 items-center">
|
||||||
<LoadingSpinner v-if="processing" class="size-icon" />
|
<div class="flex items-center justify-center w-6">
|
||||||
</div>
|
<LoadingSpinner v-if="processing" class="size-icon" />
|
||||||
</th>
|
</div>
|
||||||
<th class="!pl-1">
|
<div class="grow">Name</div>
|
||||||
<div class="flex flex-row flex-nowrap gap-2">
|
<SortCallbackButton
|
||||||
<div class="grow">Name</div>
|
initialFuncIsMine
|
||||||
<SortCallbackButton
|
v-model="sortCallback"
|
||||||
initialFuncIsMine
|
:compareFunc="sortCallbacks.name"
|
||||||
v-model="sortCallback"
|
/>
|
||||||
:compareFunc="sortCallbacks.name"
|
</div>
|
||||||
/>
|
</th>
|
||||||
</div>
|
<th v-if="settings?.directoryView?.cols?.mode">Mode</th>
|
||||||
</th>
|
<th v-if="settings?.directoryView?.cols?.owner">
|
||||||
<th v-if="settings?.directoryView?.cols?.mode">Mode</th>
|
<div class="flex flex-row flex-nowrap gap-2 items-center">
|
||||||
<th v-if="settings?.directoryView?.cols?.owner">
|
<div class="grow">Owner</div>
|
||||||
<div class="flex flex-row flex-nowrap gap-2">
|
<SortCallbackButton v-model="sortCallback" :compareFunc="sortCallbacks.owner" />
|
||||||
<div class="grow">Owner</div>
|
</div>
|
||||||
<SortCallbackButton v-model="sortCallback" :compareFunc="sortCallbacks.owner" />
|
</th>
|
||||||
</div>
|
<th v-if="settings?.directoryView?.cols?.group">
|
||||||
</th>
|
<div class="flex flex-row flex-nowrap gap-2 items-center">
|
||||||
<th v-if="settings?.directoryView?.cols?.group">
|
<div class="grow">Group</div>
|
||||||
<div class="flex flex-row flex-nowrap gap-2">
|
<SortCallbackButton v-model="sortCallback" :compareFunc="sortCallbacks.group" />
|
||||||
<div class="grow">Group</div>
|
</div>
|
||||||
<SortCallbackButton v-model="sortCallback" :compareFunc="sortCallbacks.group" />
|
</th>
|
||||||
</div>
|
<th v-if="settings?.directoryView?.cols?.size">
|
||||||
</th>
|
<div class="flex flex-row flex-nowrap gap-2 items-center">
|
||||||
<th v-if="settings?.directoryView?.cols?.size">
|
<div class="grow">Size</div>
|
||||||
<div class="flex flex-row flex-nowrap gap-2">
|
<SortCallbackButton v-model="sortCallback" :compareFunc="sortCallbacks.size" />
|
||||||
<div class="grow">Size</div>
|
</div>
|
||||||
<SortCallbackButton v-model="sortCallback" :compareFunc="sortCallbacks.size" />
|
</th>
|
||||||
</div>
|
<th v-if="settings?.directoryView?.cols?.ctime">
|
||||||
</th>
|
<div class="flex flex-row flex-nowrap gap-2 items-center">
|
||||||
<th v-if="settings?.directoryView?.cols?.ctime">
|
<div class="grow">Created</div>
|
||||||
<div class="flex flex-row flex-nowrap gap-2">
|
<SortCallbackButton v-model="sortCallback" :compareFunc="sortCallbacks.ctime" />
|
||||||
<div class="grow">Created</div>
|
</div>
|
||||||
<SortCallbackButton v-model="sortCallback" :compareFunc="sortCallbacks.ctime" />
|
</th>
|
||||||
</div>
|
<th v-if="settings?.directoryView?.cols?.mtime">
|
||||||
</th>
|
<div class="flex flex-row flex-nowrap gap-2 items-center">
|
||||||
<th v-if="settings?.directoryView?.cols?.mtime">
|
<div class="grow">Modified</div>
|
||||||
<div class="flex flex-row flex-nowrap gap-2">
|
<SortCallbackButton v-model="sortCallback" :compareFunc="sortCallbacks.mtime" />
|
||||||
<div class="grow">Modified</div>
|
</div>
|
||||||
<SortCallbackButton v-model="sortCallback" :compareFunc="sortCallbacks.mtime" />
|
</th>
|
||||||
</div>
|
<th v-if="settings?.directoryView?.cols?.atime">
|
||||||
</th>
|
<div class="flex flex-row flex-nowrap gap-2 items-center">
|
||||||
<th v-if="settings?.directoryView?.cols?.atime">
|
<div class="grow">Accessed</div>
|
||||||
<div class="flex flex-row flex-nowrap gap-2">
|
<SortCallbackButton v-model="sortCallback" :compareFunc="sortCallbacks.atime" />
|
||||||
<div class="grow">Accessed</div>
|
</div>
|
||||||
<SortCallbackButton v-model="sortCallback" :compareFunc="sortCallbacks.atime" />
|
</th>
|
||||||
</div>
|
</tr>
|
||||||
</th>
|
</template>
|
||||||
</tr>
|
<template #tbody>
|
||||||
</template>
|
<DirectoryEntryList
|
||||||
<template #tbody>
|
:path="path"
|
||||||
<DirectoryEntry
|
:sortCallback="sortCallback"
|
||||||
v-for="entry, index in entries"
|
@cd="(...args) => $emit('cd', ...args)"
|
||||||
:hidden="!settings.directoryView.showHidden && /^\./.test(entry.name)"
|
@edit="(...args) => $emit('edit', ...args)"
|
||||||
:key="entry.path"
|
@updateStats="(...args) => $emit('updateStats', ...args)"
|
||||||
:entry="entry"
|
@startProcessing="processing++"
|
||||||
listView
|
@stopProcessing="processing--"
|
||||||
|
ref="directoryEntryListRef"
|
||||||
|
:level="0"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</Table>
|
||||||
|
<div v-else class="flex flex-wrap p-2 gap-2 bg-well h-full overflow-y-auto content-start">
|
||||||
|
<DirectoryEntryList
|
||||||
|
:path="path"
|
||||||
|
:sortCallback="sortCallback"
|
||||||
@cd="(...args) => $emit('cd', ...args)"
|
@cd="(...args) => $emit('cd', ...args)"
|
||||||
@edit="(...args) => $emit('edit', ...args)"
|
@edit="(...args) => $emit('edit', ...args)"
|
||||||
@sortEntries="sortEntries"
|
@updateStats="(...args) => $emit('updateStats', ...args)"
|
||||||
@updateStats="emitStats"
|
@startProcessing="processing++"
|
||||||
|
@stopProcessing="processing--"
|
||||||
/>
|
/>
|
||||||
</template>
|
</div>
|
||||||
</Table>
|
|
||||||
<div v-else class="flex flex-wrap gap-well p-well bg-well h-full overflow-y-auto">
|
|
||||||
<DirectoryEntry
|
|
||||||
v-for="entry, index in entries"
|
|
||||||
:hidden="!settings.directoryView.showHidden && /^\./.test(entry.name)"
|
|
||||||
:key="entry.path"
|
|
||||||
:entry="entry"
|
|
||||||
@cd="(...args) => $emit('cd', ...args)"
|
|
||||||
@edit="(...args) => $emit('edit', ...args)"
|
|
||||||
@sortEntries="sortEntries"
|
|
||||||
@updateStats="emitStats"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
import { ref, reactive, computed, inject, watch } from 'vue';
|
import { ref, inject } from 'vue';
|
||||||
import DirectoryEntry from './DirectoryEntry.vue';
|
|
||||||
import { useSpawn, errorStringHTML, canonicalPath } from '@45drives/cockpit-helpers';
|
|
||||||
import Table from './Table.vue';
|
import Table from './Table.vue';
|
||||||
import { notificationsInjectionKey, settingsInjectionKey } from '../keys';
|
import { notificationsInjectionKey, settingsInjectionKey } from '../keys';
|
||||||
import LoadingSpinner from './LoadingSpinner.vue';
|
import LoadingSpinner from './LoadingSpinner.vue';
|
||||||
import SortCallbackButton from './SortCallbackButton.vue';
|
import SortCallbackButton from './SortCallbackButton.vue';
|
||||||
|
import DirectoryEntryList from './DirectoryEntryList.vue';
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
props: {
|
props: {
|
||||||
path: String,
|
path: String,
|
||||||
},
|
},
|
||||||
setup(props, { emit }) {
|
setup() {
|
||||||
const settings = inject(settingsInjectionKey);
|
const settings = inject(settingsInjectionKey);
|
||||||
const entries = ref([]);
|
|
||||||
const processing = ref(0);
|
const processing = ref(0);
|
||||||
const notifications = inject(notificationsInjectionKey);
|
const directoryEntryListRef = ref();
|
||||||
|
|
||||||
const sortCallbacks = {
|
const sortCallbacks = {
|
||||||
name: (a, b) => a.name.localeCompare(b.name),
|
name: (a, b) => a.name.localeCompare(b.name),
|
||||||
@ -121,235 +117,22 @@ export default {
|
|||||||
atime: (a, b) => a.atime.getTime() - b.atime.getTime(),
|
atime: (a, b) => a.atime.getTime() - b.atime.getTime(),
|
||||||
}
|
}
|
||||||
const sortCallback = ref(() => 0);
|
const sortCallback = ref(() => 0);
|
||||||
const sortCallbackComputed = computed({
|
|
||||||
get() {
|
|
||||||
return (a, b) => {
|
|
||||||
if (settings.directoryView?.separateDirs) {
|
|
||||||
const checkA = a.type === 'link' ? (a.target?.type ?? null) : a.type;
|
|
||||||
const checkB = b.type === '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 sortCallback.value(a, b);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
set(value) {
|
|
||||||
sortCallback.value = value;
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
const getAsyncEntryStats = (cwd, entry, modeStr, path, linkTargetRaw) => {
|
const getEntries = () => {
|
||||||
const procs = [];
|
return directoryEntryListRef.value?.getEntries?.();
|
||||||
Object.assign(entry, {
|
|
||||||
permissions: {
|
|
||||||
owner: {
|
|
||||||
read: modeStr[1] !== '-',
|
|
||||||
write: modeStr[2] !== '-',
|
|
||||||
execute: modeStr[3] !== '-',
|
|
||||||
},
|
|
||||||
group: {
|
|
||||||
read: modeStr[4] !== '-',
|
|
||||||
write: modeStr[5] !== '-',
|
|
||||||
execute: modeStr[6] !== '-',
|
|
||||||
},
|
|
||||||
other: {
|
|
||||||
read: modeStr[7] !== '-',
|
|
||||||
write: modeStr[8] !== '-',
|
|
||||||
execute: modeStr[9] !== '-',
|
|
||||||
},
|
|
||||||
acl: modeStr[10] === '+' ? {} : null,
|
|
||||||
}
|
|
||||||
});
|
|
||||||
switch (modeStr[0]) {
|
|
||||||
case 'd':
|
|
||||||
entry.type = 'directory';
|
|
||||||
break;
|
|
||||||
case '-':
|
|
||||||
entry.type = 'file';
|
|
||||||
break;
|
|
||||||
case 'p':
|
|
||||||
entry.type = 'pipe';
|
|
||||||
break;
|
|
||||||
case 'l':
|
|
||||||
entry.type = 'link';
|
|
||||||
if (linkTargetRaw) {
|
|
||||||
entry.target = {
|
|
||||||
rawPath: linkTargetRaw,
|
|
||||||
path: canonicalPath(linkTargetRaw.replace(/^(?!=\/)/, cwd + '/')),
|
|
||||||
};
|
|
||||||
processing.value++;
|
|
||||||
procs.push(useSpawn(['stat', '-c', '%A', entry.target.path]).promise()
|
|
||||||
.then(state => {
|
|
||||||
getAsyncEntryStats(cwd, entry.target, state.stdout.trim());
|
|
||||||
entry.target.broken = false;
|
|
||||||
})
|
|
||||||
.catch(() => {
|
|
||||||
entry.target.broken = true;
|
|
||||||
})
|
|
||||||
.finally(() => processing.value--)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
case 's':
|
|
||||||
entry.type = 'socket';
|
|
||||||
break;
|
|
||||||
case 'c':
|
|
||||||
entry.type = 'character';
|
|
||||||
break;
|
|
||||||
case 'b':
|
|
||||||
entry.type = 'block';
|
|
||||||
break;
|
|
||||||
default:
|
|
||||||
entry.type = 'unk';
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
if (entry.permissions.acl && path) {
|
|
||||||
processing.value++;
|
|
||||||
procs.push(useSpawn(['getfacl', '--omit-header', '--no-effective', path], { superuser: 'try' }).promise()
|
|
||||||
.then(state => {
|
|
||||||
entry.permissions.acl = state.stdout
|
|
||||||
.split('\n')
|
|
||||||
.filter(line => line && !/^\s*(?:#|$)/.test(line))
|
|
||||||
.reduce((acl, line) => {
|
|
||||||
const match = line.match(/^([^:]*):([^:]+)?:(.*)$/).slice(1);
|
|
||||||
acl[match[0]] = acl[match[0]] ?? {};
|
|
||||||
acl[match[0]][match[1] ?? '*'] = {
|
|
||||||
r: match[2][0] !== '-',
|
|
||||||
w: match[2][1] !== '-',
|
|
||||||
x: match[2][2] !== '-',
|
|
||||||
}
|
|
||||||
return acl;
|
|
||||||
}, {});
|
|
||||||
})
|
|
||||||
.catch(state => {
|
|
||||||
console.error(`failed to get ACL for ${path}:`, errorString(state));
|
|
||||||
})
|
|
||||||
.finally(() => processing.value--)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (path) {
|
|
||||||
processing.value++;
|
|
||||||
procs.push(useSpawn(['stat', '-c', '%W:%Y:%X', path], { superuser: 'try' }).promise() // birth:modification:access
|
|
||||||
.then(state => {
|
|
||||||
const [ctimeStr, mtimeStr, atimeStr] = state.stdout.trim().split(':');
|
|
||||||
Object.assign(entry, {
|
|
||||||
ctime: new Date(parseInt(ctimeStr) * 1000),
|
|
||||||
mtime: new Date(parseInt(mtimeStr) * 1000),
|
|
||||||
atime: new Date(parseInt(atimeStr) * 1000),
|
|
||||||
});
|
|
||||||
})
|
|
||||||
.catch(state =>
|
|
||||||
notifications.value.constructNotification(`Failed to get stats for ${path}`, errorStringHTML(state), 'error')
|
|
||||||
)
|
|
||||||
.finally(() => processing.value--)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return Promise.all(procs);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const getEntries = async () => {
|
|
||||||
processing.value++;
|
|
||||||
try {
|
|
||||||
const cwd = props.path;
|
|
||||||
let lsOutput;
|
|
||||||
try {
|
|
||||||
lsOutput = (await useSpawn(['ls', '-al', '--color=never', '--time-style=full-iso', '--quote-name', '--dereference-command-line-symlink-to-dir', cwd], { superuser: 'try' }).promise()).stdout
|
|
||||||
} catch (state) {
|
|
||||||
if (state.exit_code === 1)
|
|
||||||
lsOutput = state.stdout; // non-fatal ls error
|
|
||||||
else
|
|
||||||
throw new Error(state.stderr);
|
|
||||||
}
|
|
||||||
const procs = [];
|
|
||||||
entries.value = lsOutput
|
|
||||||
.split('\n')
|
|
||||||
.filter(line => !/^(?:\s*$|total)/.test(line)) // remove empty lines
|
|
||||||
.map(record => {
|
|
||||||
try {
|
|
||||||
if (cwd !== props.path)
|
|
||||||
return null;
|
|
||||||
const entry = reactive({});
|
|
||||||
const fields = record.match(/^([a-z-]+\+?)\s+(\d+)\s+(\S+)\s+(\S+)\s+(\d+(?:,\s+\d+)?)\s+([^"]+)"([^"]+)"(?:\s+->\s+"([^"]+)")?/)?.slice(1);
|
|
||||||
if (!fields) {
|
|
||||||
console.error('regex failed to match on', record);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
entry.name = fields[6];
|
|
||||||
if (entry.name === '.' || entry.name === '..')
|
|
||||||
return null;
|
|
||||||
entry.path = canonicalPath(cwd + `/${entry.name}`);
|
|
||||||
entry.modeStr = fields[0];
|
|
||||||
entry.hardlinkCount = parseInt(fields[1]);
|
|
||||||
entry.owner = fields[2];
|
|
||||||
entry.group = fields[3];
|
|
||||||
if (/,/.test(fields[4])) {
|
|
||||||
[entry.major, entry.minor] = fields[4].split(/,\s+/);
|
|
||||||
entry.size = null;
|
|
||||||
} else {
|
|
||||||
entry.size = parseInt(fields[4]);
|
|
||||||
entry.sizeHuman = cockpit.format_bytes(entry.size, 1000).replace(/(?<!B)$/, ' B');
|
|
||||||
entry.major = entry.minor = null;
|
|
||||||
}
|
|
||||||
procs.push(getAsyncEntryStats(cwd, entry, entry.modeStr, entry.path, fields[7]));
|
|
||||||
return entry;
|
|
||||||
} catch (error) {
|
|
||||||
notifications.value.constructNotification(`Error while gathering info for ${entry.path ?? record}`, errorStringHTML(error), 'error');
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.filter(entry => entry !== null)
|
|
||||||
?? [];
|
|
||||||
Promise.all(procs).then(() => {
|
|
||||||
emitStats();
|
|
||||||
sortEntries();
|
|
||||||
})
|
|
||||||
} catch (error) {
|
|
||||||
entries.value = [];
|
|
||||||
notifications.value.constructNotification("Error getting directory entries", errorStringHTML(error), 'error');
|
|
||||||
} finally {
|
|
||||||
processing.value--;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const emitStats = () => {
|
|
||||||
emit('updateStats', entries.value.reduce((stats, entry) => {
|
|
||||||
if (entry.type === 'directory' || (entry.type === 'link' && entry.target?.type === 'directory'))
|
|
||||||
stats.dirs++;
|
|
||||||
else
|
|
||||||
stats.files++;
|
|
||||||
return stats;
|
|
||||||
}, { files: 0, dirs: 0 }));
|
|
||||||
}
|
|
||||||
|
|
||||||
const sortEntries = () => {
|
|
||||||
processing.value++;
|
|
||||||
entries.value.sort(sortCallbackComputed.value);
|
|
||||||
processing.value--;
|
|
||||||
}
|
|
||||||
|
|
||||||
watch(sortCallback, sortEntries);
|
|
||||||
watch(entries, sortEntries);
|
|
||||||
watch(() => settings.directoryView?.separateDirs, sortEntries);
|
|
||||||
|
|
||||||
watch(() => props.path, getEntries, { immediate: true });
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
settings,
|
settings,
|
||||||
entries,
|
|
||||||
processing,
|
processing,
|
||||||
|
directoryEntryListRef,
|
||||||
sortCallbacks,
|
sortCallbacks,
|
||||||
sortCallback,
|
sortCallback,
|
||||||
getEntries,
|
getEntries,
|
||||||
emitStats,
|
|
||||||
sortEntries,
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
components: {
|
components: {
|
||||||
DirectoryEntry,
|
DirectoryEntryList,
|
||||||
Table,
|
Table,
|
||||||
LoadingSpinner,
|
LoadingSpinner,
|
||||||
SortCallbackButton,
|
SortCallbackButton,
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="flex items-center cursor-text h-10" @click="typing = true">
|
<div class="flex items-center cursor-text h-10" @click="typing = true">
|
||||||
<input v-if="typing" v-model="pathInput" type="text" class="w-full input-textlike" @change="$emit('cd', canonicalPath(pathInput))" ref="inputRef" @focusout="typing = false" />
|
<input v-if="typing" v-model="pathInput" type="text" class="block w-full input-textlike" @change="$emit('cd', canonicalPath(pathInput))" ref="inputRef" @focusout="typing = false" />
|
||||||
<div v-else class="inline-flex items-center gap-1">
|
<div v-else class="inline-flex items-center gap-1">
|
||||||
<template v-for="segment, index in pathArr" :key="index">
|
<template v-for="segment, index in pathArr" :key="index">
|
||||||
<ChevronRightIcon v-if="index > 0" class="size-icon icon-default" />
|
<ChevronRightIcon v-if="index > 0" class="size-icon icon-default" />
|
||||||
|
@ -16,7 +16,7 @@
|
|||||||
<LabelledSwitch v-model="settings.directoryView.showHidden">Show hidden files</LabelledSwitch>
|
<LabelledSwitch v-model="settings.directoryView.showHidden">Show hidden files</LabelledSwitch>
|
||||||
<LabelledSwitch
|
<LabelledSwitch
|
||||||
v-model="booleanAnalogs.directoryView.view.bool"
|
v-model="booleanAnalogs.directoryView.view.bool"
|
||||||
>{{ booleanAnalogs.directoryView.view.bool ? "List view" : "Grid view" }}</LabelledSwitch>
|
>List view</LabelledSwitch>
|
||||||
<LabelledSwitch v-model="settings.directoryView.separateDirs">Separate directories while sorting</LabelledSwitch>
|
<LabelledSwitch v-model="settings.directoryView.separateDirs">Separate directories while sorting</LabelledSwitch>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="booleanAnalogs.directoryView.view.bool" class="self-stretch">
|
<div v-if="booleanAnalogs.directoryView.view.bool" class="self-stretch">
|
||||||
@ -198,7 +198,6 @@ export default {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
console.log(settings);
|
|
||||||
|
|
||||||
watch(settings, () => {
|
watch(settings, () => {
|
||||||
localStorage.setItem(settingsStorageKey, JSON.stringify(settings));
|
localStorage.setItem(settingsStorageKey, JSON.stringify(settings));
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
import { useSpawn } from '@45drives/cockpit-helpers';
|
import { useSpawn, errorString } from '@45drives/cockpit-helpers';
|
||||||
|
|
||||||
/** Run test with given expression and return boolean result. Throws on abnormal errors.
|
/** Run test with given expression and return boolean result. Throws on abnormal errors.
|
||||||
*
|
*
|
||||||
|
@ -24,7 +24,6 @@ const router = createRouter({
|
|||||||
});
|
});
|
||||||
|
|
||||||
router.beforeEach((to, from, next) => {
|
router.beforeEach((to, from, next) => {
|
||||||
console.log(to);
|
|
||||||
if (to.name === 'redirectToBrowse') {
|
if (to.name === 'redirectToBrowse') {
|
||||||
const lastLocation = localStorage.getItem(lastPathStorageKey) ?? '/';
|
const lastLocation = localStorage.getItem(lastPathStorageKey) ?? '/';
|
||||||
next(`/browse${lastLocation}`);
|
next(`/browse${lastLocation}`);
|
||||||
|
@ -1,71 +1,88 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="grow overflow-hidden">
|
<div class="grow overflow-hidden">
|
||||||
<div class="h-full flex flex-col items-stretch">
|
<div class="h-full flex flex-col items-stretch">
|
||||||
<div class="flex gap-buttons items-stretch divide-x divide-default">
|
<div class="flex items-stretch divide-x divide-y divide-default flex-wrap">
|
||||||
<div class="button-group-row px-4 py-2">
|
<div class="flex items-stretch divide-x divide-default grow-[6] basis-0">
|
||||||
<button
|
<div class="button-group-row px-4 py-2">
|
||||||
class="p-2 rounded-lg hover:bg-accent relative"
|
<button
|
||||||
:disabled="!pathHistory.backAllowed()"
|
class="p-2 rounded-lg hover:bg-accent relative"
|
||||||
@click="back()"
|
:disabled="!pathHistory.backAllowed()"
|
||||||
@mouseenter="backHistoryDropdown.mouseEnter"
|
@click="back()"
|
||||||
@mouseleave="backHistoryDropdown.mouseLeave"
|
@mouseenter="backHistoryDropdown.mouseEnter"
|
||||||
>
|
@mouseleave="backHistoryDropdown.mouseLeave"
|
||||||
<ArrowLeftIcon class="size-icon icon-default" />
|
|
||||||
<ChevronDownIcon
|
|
||||||
class="w-3 h-3 icon-default absolute bottom-1 right-1"
|
|
||||||
v-if="pathHistory.backAllowed()"
|
|
||||||
/>
|
|
||||||
<div
|
|
||||||
v-if="backHistoryDropdown.showDropdown"
|
|
||||||
class="absolute top-full left-0 flex flex-col items-stretch z-50 bg-default shadow-lg rounded-lg overflow-y-auto max-h-80"
|
|
||||||
>
|
>
|
||||||
|
<ArrowLeftIcon class="size-icon icon-default" />
|
||||||
|
<ChevronDownIcon
|
||||||
|
class="w-3 h-3 icon-default absolute bottom-1 right-1"
|
||||||
|
v-if="pathHistory.backAllowed()"
|
||||||
|
/>
|
||||||
<div
|
<div
|
||||||
v-for="item, index in pathHistory.stack.slice(0, pathHistory.index).reverse()"
|
v-if="backHistoryDropdown.showDropdown"
|
||||||
:key="index"
|
class="absolute top-full left-0 flex flex-col items-stretch z-50 bg-default shadow-lg rounded-lg overflow-y-auto max-h-80"
|
||||||
@click="pathHistory.index = pathHistory.index - index"
|
>
|
||||||
class="hover:text-white hover:bg-red-600 px-4 py-2 text-sm text-left whitespace-nowrap"
|
<div
|
||||||
>{{ item }}</div>
|
v-for="item, index in pathHistory.stack.slice(0, pathHistory.index).reverse()"
|
||||||
</div>
|
:key="index"
|
||||||
</button>
|
@click="pathHistory.index = pathHistory.index - index"
|
||||||
<button
|
class="hover:text-white hover:bg-red-600 px-4 py-2 text-sm text-left whitespace-nowrap"
|
||||||
class="p-2 rounded-lg hover:bg-accent relative"
|
>{{ item }}</div>
|
||||||
:disabled="!pathHistory.forwardAllowed()"
|
</div>
|
||||||
@click="forward()"
|
</button>
|
||||||
@mouseenter="forwardHistoryDropdown.mouseEnter"
|
<button
|
||||||
@mouseleave="forwardHistoryDropdown.mouseLeave"
|
class="p-2 rounded-lg hover:bg-accent relative"
|
||||||
>
|
:disabled="!pathHistory.forwardAllowed()"
|
||||||
<ArrowRightIcon class="size-icon icon-default" />
|
@click="forward()"
|
||||||
<ChevronDownIcon
|
@mouseenter="forwardHistoryDropdown.mouseEnter"
|
||||||
class="w-3 h-3 icon-default absolute bottom-1 right-1"
|
@mouseleave="forwardHistoryDropdown.mouseLeave"
|
||||||
v-if="pathHistory.forwardAllowed()"
|
|
||||||
/>
|
|
||||||
<div
|
|
||||||
v-if="forwardHistoryDropdown.showDropdown"
|
|
||||||
class="absolute top-full left-0 flex flex-col items-stretch z-50 bg-default shadow-lg rounded-lg overflow-y-auto max-h-80"
|
|
||||||
>
|
>
|
||||||
|
<ArrowRightIcon class="size-icon icon-default" />
|
||||||
|
<ChevronDownIcon
|
||||||
|
class="w-3 h-3 icon-default absolute bottom-1 right-1"
|
||||||
|
v-if="pathHistory.forwardAllowed()"
|
||||||
|
/>
|
||||||
<div
|
<div
|
||||||
v-for="item, index in pathHistory.stack.slice(pathHistory.index + 1)"
|
v-if="forwardHistoryDropdown.showDropdown"
|
||||||
:key="index"
|
class="absolute top-full left-0 flex flex-col items-stretch z-50 bg-default shadow-lg rounded-lg overflow-y-auto max-h-80"
|
||||||
@click="pathHistory.index = pathHistory.index + index"
|
>
|
||||||
class="hover:text-white hover:bg-red-600 px-4 py-2 text-sm text-left whitespace-nowrap"
|
<div
|
||||||
>{{ item }}</div>
|
v-for="item, index in pathHistory.stack.slice(pathHistory.index + 1)"
|
||||||
</div>
|
:key="index"
|
||||||
</button>
|
@click="pathHistory.index = pathHistory.index + index"
|
||||||
<button class="p-2 rounded-lg hover:bg-accent" @click="up()">
|
class="hover:text-white hover:bg-red-600 px-4 py-2 text-sm text-left whitespace-nowrap"
|
||||||
<ArrowUpIcon class="size-icon icon-default" />
|
>{{ item }}</div>
|
||||||
</button>
|
</div>
|
||||||
<button class="p-2 rounded-lg hover:bg-accent" @click="directoryViewRef.getEntries()">
|
</button>
|
||||||
<RefreshIcon class="size-icon icon-default" />
|
<button class="p-2 rounded-lg hover:bg-accent" @click="up()">
|
||||||
</button>
|
<ArrowUpIcon class="size-icon icon-default" />
|
||||||
|
</button>
|
||||||
|
<button class="p-2 rounded-lg hover:bg-accent" @click="directoryViewRef.getEntries()">
|
||||||
|
<RefreshIcon class="size-icon icon-default" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="grow px-4 py-2">
|
||||||
|
<PathBreadCrumbs :path="pathHistory.current() ?? '/'" @cd="newPath => cd(newPath)" />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="grow card-header px-4 py-2">
|
|
||||||
<PathBreadCrumbs :path="pathHistory.current() ?? '/'" @cd="newPath => cd(newPath)" />
|
<div class="grow shrink-0 px-4 py-2">
|
||||||
|
<div class="relative">
|
||||||
|
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||||
|
<SearchIcon class="size-icon icon-default" aria-hidden="true" />
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
class="block input-textlike w-full pl-10"
|
||||||
|
v-model="searchFilter"
|
||||||
|
placeholder="Search in directory"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="grow overflow-hidden">
|
<div class="grow overflow-hidden">
|
||||||
<DirectoryView
|
<DirectoryView
|
||||||
:path="pathHistory.current() ?? '/'"
|
:path="pathHistory.current() ?? '/'"
|
||||||
@cd="newPath => cd(newPath)"
|
@cd="newPath => cd(newPath)"
|
||||||
|
@edit="(...args) => console.log('edit', ...args)"
|
||||||
@updateStats="stats => $emit('updateFooterText', `${stats.files} file${stats.files === 1 ? '' : 's'}, ${stats.dirs} director${stats.dirs === 1 ? 'y' : 'ies'}`)"
|
@updateStats="stats => $emit('updateFooterText', `${stats.files} file${stats.files === 1 ? '' : 's'}, ${stats.dirs} director${stats.dirs === 1 ? 'y' : 'ies'}`)"
|
||||||
ref="directoryViewRef"
|
ref="directoryViewRef"
|
||||||
/>
|
/>
|
||||||
@ -82,7 +99,7 @@ import { errorStringHTML, canonicalPath } from '@45drives/cockpit-helpers';
|
|||||||
import PathBreadCrumbs from '../components/PathBreadCrumbs.vue';
|
import PathBreadCrumbs from '../components/PathBreadCrumbs.vue';
|
||||||
import { checkIfExists, checkIfAllowed } from '../mode';
|
import { checkIfExists, checkIfAllowed } from '../mode';
|
||||||
import { notificationsInjectionKey, pathHistoryInjectionKey, lastPathStorageKey } from '../keys';
|
import { notificationsInjectionKey, pathHistoryInjectionKey, lastPathStorageKey } from '../keys';
|
||||||
import { ArrowLeftIcon, ArrowRightIcon, ArrowUpIcon, RefreshIcon, ChevronDownIcon } from '@heroicons/vue/solid';
|
import { ArrowLeftIcon, ArrowRightIcon, ArrowUpIcon, RefreshIcon, ChevronDownIcon, SearchIcon } from '@heroicons/vue/solid';
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
setup() {
|
setup() {
|
||||||
@ -90,6 +107,7 @@ export default {
|
|||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
const pathHistory = inject(pathHistoryInjectionKey);
|
const pathHistory = inject(pathHistoryInjectionKey);
|
||||||
const directoryViewRef = ref();
|
const directoryViewRef = ref();
|
||||||
|
const searchFilter = ref("");
|
||||||
const backHistoryDropdown = reactive({
|
const backHistoryDropdown = reactive({
|
||||||
showDropdown: false,
|
showDropdown: false,
|
||||||
timeoutHandle: null,
|
timeoutHandle: null,
|
||||||
@ -173,8 +191,10 @@ export default {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
cockpit,
|
cockpit,
|
||||||
|
console,
|
||||||
pathHistory,
|
pathHistory,
|
||||||
directoryViewRef,
|
directoryViewRef,
|
||||||
|
searchFilter,
|
||||||
backHistoryDropdown,
|
backHistoryDropdown,
|
||||||
forwardHistoryDropdown,
|
forwardHistoryDropdown,
|
||||||
cd,
|
cd,
|
||||||
@ -191,6 +211,7 @@ export default {
|
|||||||
ArrowUpIcon,
|
ArrowUpIcon,
|
||||||
RefreshIcon,
|
RefreshIcon,
|
||||||
ChevronDownIcon,
|
ChevronDownIcon,
|
||||||
|
SearchIcon,
|
||||||
},
|
},
|
||||||
emits: [
|
emits: [
|
||||||
'updateFooterText'
|
'updateFooterText'
|
||||||
|
@ -687,9 +687,9 @@ reusify@^1.0.4:
|
|||||||
integrity sha512-U9nH88a3fc/ekCF1l0/UP1IosiuIjyTh7hBvXVMHYgVcfGvt897Xguj2UOLDeI5BG2m7/uwyaLVT6fbtCwTyzw==
|
integrity sha512-U9nH88a3fc/ekCF1l0/UP1IosiuIjyTh7hBvXVMHYgVcfGvt897Xguj2UOLDeI5BG2m7/uwyaLVT6fbtCwTyzw==
|
||||||
|
|
||||||
rollup@^2.59.0:
|
rollup@^2.59.0:
|
||||||
version "2.72.1"
|
version "2.73.0"
|
||||||
resolved "https://registry.yarnpkg.com/rollup/-/rollup-2.72.1.tgz#861c94790537b10008f0ca0fbc60e631aabdd045"
|
resolved "https://registry.yarnpkg.com/rollup/-/rollup-2.73.0.tgz#128fef4b333fd92d02d6929afbb6ee38d7feb32d"
|
||||||
integrity sha512-NTc5UGy/NWFGpSqF1lFY8z9Adri6uhyMLI6LvPAXdBKoPRFhIIiBUpt+Qg2awixqO3xvzSijjhnb4+QEZwJmxA==
|
integrity sha512-h/UngC3S4Zt28mB3g0+2YCMegT5yoftnQplwzPqGZcKvlld5e+kT/QRmJiL+qxGyZKOYpgirWGdLyEO1b0dpLQ==
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
fsevents "~2.3.2"
|
fsevents "~2.3.2"
|
||||||
|
|
||||||
|
Loading…
x
Reference in New Issue
Block a user