clean up navigation with router

This commit is contained in:
joshuaboud 2022-05-18 14:28:24 -03:00
parent 523166d424
commit a716954e1f
No known key found for this signature in database
GPG Key ID: 17EFB59E2A8BF50E
8 changed files with 208 additions and 90 deletions

View File

@ -7,7 +7,7 @@
<component :is="icon" class="size-icon icon-default" /> <component :is="icon" class="size-icon icon-default" />
<LinkIcon v-if="entry.type === 'symbolic link'" class="w-2 h-2 absolute right-0 bottom-0 text-default" /> <LinkIcon v-if="entry.type === 'symbolic link'" class="w-2 h-2 absolute right-0 bottom-0 text-default" />
</div> </div>
<button v-if="directoryLike" @click.stop="() => showEntries = !showEntries"> <button v-if="directoryLike" @click.stop="toggleShowEntries">
<ChevronDownIcon v-if="!showEntries" class="size-icon icon-default" /> <ChevronDownIcon v-if="!showEntries" class="size-icon icon-default" />
<ChevronUpIcon v-else class="size-icon icon-default" /> <ChevronUpIcon v-else class="size-icon icon-default" />
</button> </button>
@ -107,6 +107,14 @@ export default {
return directoryViewRef.value?.getEntries?.(); return directoryViewRef.value?.getEntries?.();
} }
const toggleShowEntries = () => {
emit('startProcessing');
nextTick(() => {
showEntries.value = !showEntries.value;
nextTick(() => emit('stopProcessing'));
})
}
watch(props.entry, () => { watch(props.entry, () => {
if (props.entry.type === 'directory' || (props.entry.type === 'symbolic link' && props.entry.target?.type === 'directory')) { if (props.entry.type === 'directory' || (props.entry.type === 'symbolic link' && props.entry.target?.type === 'directory')) {
icon.value = FolderIcon; icon.value = FolderIcon;
@ -125,6 +133,7 @@ export default {
directoryViewRef, directoryViewRef,
doubleClickCallback, doubleClickCallback,
getEntries, getEntries,
toggleShowEntries,
DirectoryEntryList, DirectoryEntryList,
nextTick, nextTick,
} }

View File

@ -90,30 +90,40 @@ export default {
} }
const getEntries = async () => { const getEntries = async () => {
processingHandler.start(); if (!props.path) {
const readLink = (target, cwd, symlinkStr) => { return;
return new Promise((resolve, reject) => {
const linkTargetRaw = symlinkStr.split(/\s*->\s*/)[1].trim().replace(/^['"]|['"]$/g, '');
Object.assign(target, {
rawPath: linkTargetRaw,
path: canonicalPath(linkTargetRaw.replace(/^(?!\/)/, `${cwd}/`)),
});
useSpawn(['stat', '-c', '%F', target.path], { superuser: 'try' }).promise()
.then(state => {
target.type = state.stdout.trim();
target.broken = false;
})
.catch(() => {
target.broken = true;
})
.finally(resolve);
})
} }
processingHandler.start();
const US = '\x1F'; const US = '\x1F';
const RS = '\x1E'; const RS = '\x1E';
const processLinks = (linkTargets) => {
if (linkTargets.length === 0)
return null;
const callback = state => state.stdout
.trim()
.split('\n')
.filter(record => record)
.map((record, index) => {
if (record.includes(US)) {
const [type, mode] = record.split(US);
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${US}%f\n`, ...linkTargets.map(target => target.path)], { superuser: 'try', err: 'out' }).promise()
.then(callback)
.catch(callback)
.finally(resolve)
)
}
try { try {
const cwd = props.path; const cwd = props.path;
const procs = []; const procs = [];
let tmpEntries;
procs.push(...entryRefs.value.filter(entryRef => entryRef.showEntries).map(entryRef => entryRef.getEntries())); procs.push(...entryRefs.value.filter(entryRef => entryRef.showEntries).map(entryRef => entryRef.getEntries()));
const entryNames = const entryNames =
(await useSpawn(['dir', '--almost-all', '--dereference-command-line-symlink-to-dir', '--quoting-style=c', '-1', cwd], { superuser: 'try' }).promise()).stdout (await useSpawn(['dir', '--almost-all', '--dereference-command-line-symlink-to-dir', '--quoting-style=c', '-1', cwd], { superuser: 'try' }).promise()).stdout
@ -123,7 +133,7 @@ export default {
try { try {
return JSON.parse(escaped); return JSON.parse(escaped);
} catch (error) { } catch (error) {
notifications.constructNotification("Failed to parse file name", `${errorStringHTML(error)}\ncaused by ${escaped}`, 'error'); notifications.value.constructNotification("Failed to parse file name", `${errorStringHTML(error)}\ncaused by ${escaped}`, 'error');
return null; return null;
} }
}) })
@ -141,7 +151,7 @@ export default {
'%F', // type '%F', // type
'%N', // quoted name with symlink '%N', // quoted name with symlink
] ]
entries.value = tmpEntries =
entryNames.length entryNames.length
? (await useSpawn(['stat', `--printf=${fields.join(US)}${RS}`, ...entryNames], { superuser: 'try', directory: cwd }).promise()).stdout ? (await useSpawn(['stat', `--printf=${fields.join(US)}${RS}`, ...entryNames], { superuser: 'try', directory: cwd }).promise()).stdout
.split(RS) .split(RS)
@ -154,7 +164,7 @@ export default {
mode = parseInt(mode, 16); mode = parseInt(mode, 16);
const entry = reactive({ const entry = reactive({
name, name,
path: canonicalPath(`/${cwd}/${name}`), path: `${cwd}/${name}`.replace(/\/+/g, '/'),
mode, mode,
modeStr, modeStr,
size, size,
@ -167,8 +177,10 @@ export default {
type, type,
target: {}, target: {},
}); });
if (type === 'symbolic link') if (type === 'symbolic link') {
procs.push(readLink(entry.target, cwd, symlinkStr)); entry.target.rawPath = symlinkStr.split(/\s*->\s*/)[1].trim().replace(/^['"]|['"]$/g, '');
entry.target.path = entry.target.rawPath.replace(/^(?!\/)/, `${cwd}/`);
}
return entry; return entry;
} catch (error) { } catch (error) {
console.error(errorString(error)); console.error(errorString(error));
@ -176,10 +188,13 @@ export default {
} }
}).filter(entry => entry !== null) }).filter(entry => entry !== null)
: []; : [];
procs.push(processLinks(tmpEntries.filter(entry => entry.type === 'symbolic link').map(entry => entry.target)));
processingHandler.start(); processingHandler.start();
console.log("resolving", procs.length, 'symlinks');
return Promise.all(procs) return Promise.all(procs)
.then(() => { .then(() => {
if (props.path !== cwd)
return;
entries.value = [...tmpEntries];
emitStats(); emitStats();
sortEntries(); sortEntries();
}) })
@ -223,7 +238,11 @@ export default {
watch(() => props.sortCallback, sortEntries); watch(() => props.sortCallback, sortEntries);
watch(() => settings.directoryView?.separateDirs, sortEntries); watch(() => settings.directoryView?.separateDirs, sortEntries);
watch(() => props.path, getEntries, { immediate: true }); watch(() => props.path, (current, old) => {
if (current === old)
return;
getEntries();
}, { immediate: true });
return { return {
settings, settings,

View File

@ -5,7 +5,7 @@
<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" />
<button <button
@click.prevent.stop="$emit('cd', canonicalPath(pathArr.slice(0, index + 1).join('/')))" @click.prevent.stop="$emit('cd', `/${pathArr.slice(1, index + 1).join('/')}`)"
class="p-2 hover:bg-accent rounded-lg cursor-pointer" class="p-2 hover:bg-accent rounded-lg cursor-pointer"
>{{ segment }}</button> >{{ segment }}</button>
</template> </template>

View File

@ -1,16 +1,17 @@
import { createApp, reactive } from 'vue'; import { createApp, reactive } from 'vue';
import App from './App.vue'; import App from './App.vue';
import { FIFO } from '@45drives/cockpit-helpers'; import { errorString, FIFO } from '@45drives/cockpit-helpers';
import '@45drives/cockpit-css/src/index.css'; import '@45drives/cockpit-css/src/index.css';
import { useSpawn, errorStringHTML } from '@45drives/cockpit-helpers';
import router from './router'; import router from './router';
const notificationFIFO = reactive(new FIFO()); const notificationFIFO = reactive(new FIFO());
const errorHandler = (error) => { const errorHandler = (error, title = "System Error") => {
console.error(error); console.error(error);
const notificationObj = { const notificationObj = {
title: "System Error", title,
body: "", body: "",
show: true, show: true,
timeout: 10000, timeout: 10000,
@ -32,6 +33,33 @@ const errorHandler = (error) => {
throw error; throw error;
} }
router.beforeEach(async (to, from) => {
if (to.name === 'browse') {
if (!to.params.path)
return "/browse/"; // force / for opening root
try {
let realPath = (await useSpawn(['realpath', '--canonicalize-existing', to.params.path], { superuser: 'try' }).promise()).stdout.trim();
if (to.params.path !== realPath)
return `/browse${realPath}`;
try {
await useSpawn(['test', '-r', to.params.path, '-a', '-x', to.params.path], { superuser: 'try' }).promise();
} catch {
throw new Error(`Permission denied for ${to.params.path}`);
}
} catch (error) {
if (from.name === undefined)
return { name: 'errorRedirect', query: { title: "Error opening path", message: errorString(error) } }
errorHandler(errorStringHTML(error), "Failed to open path");
return false;
}
}
if (cockpit.location.href !== to.fullPath.replace(/\/$/, '') && to.name !== 'errorRedirect') {
cockpit.location.replace(to.fullPath);
return false; // avoid double render from router change and cockpit path change
}
return true;
})
const app = createApp(App, { notificationFIFO }).use(router); const app = createApp(App, { notificationFIFO }).use(router);
app.config.errorHandler = (error) => errorHandler(error); app.config.errorHandler = (error) => errorHandler(error);

View File

@ -1,21 +1,27 @@
import { createRouter, createWebHashHistory } from 'vue-router'; import { createRouter, createWebHashHistory } from 'vue-router';
import { lastPathStorageKey } from '../keys';
const routes = [ const routes = [
{ {
path: '/browse:path(.+)', path: '/browse:path(/.*)?',
name: 'browse', name: 'browse',
component: () => import('../views/Browser.vue'), component: () => import('../views/Browser.vue'),
}, },
{ {
path: '/edit/:path(.*)', path: '/edit:path(/.+)',
name: 'edit', name: 'edit',
component: () => import('../views/Editor.vue'), component: () => import('../views/Editor.vue'),
}, },
{ {
path: '/:pathMatch(.*)*', path: '/errorRedirect',
name: 'redirectToBrowse', name: 'errorRedirect',
component: () => import('../views/ErrorRedirect.vue'),
props: route => ({ title: route.query.title, message: route.query.message }),
}, },
{
path: '/:pathMatch(.*)',
name: 'notFound',
redirect: route => ({ name: 'errorRedirect', query: { title: 'Not found', message: `${route.href} is not a valid location.` }}),
}
]; ];
const router = createRouter({ const router = createRouter({
@ -23,14 +29,4 @@ const router = createRouter({
routes, routes,
}); });
router.beforeEach((to, from, next) => {
if (to.name === 'redirectToBrowse') {
const lastLocation = localStorage.getItem(lastPathStorageKey) ?? '/';
next(`/browse${lastLocation}`);
cockpit.location.go(`/browse${lastLocation}`);
} else {
next();
}
})
export default router; export default router;

View File

@ -4,7 +4,7 @@
<div <div
class="grid grid-cols-[auto_1fr] grid-rows-[1fr 1fr] md:grid-cols-[auto_3fr_1fr] md:grid-row-[1fr] items-stretch divide-x divide-y divide-default" class="grid grid-cols-[auto_1fr] grid-rows-[1fr 1fr] md:grid-cols-[auto_3fr_1fr] md:grid-row-[1fr] items-stretch divide-x divide-y divide-default"
> >
<div class="button-group-row p-1 md:px-4 md:py-2"> <div class="button-group-row p-1 md:px-4 md:py-2 border border-default">
<button <button
class="p-2 rounded-lg hover:bg-accent relative" class="p-2 rounded-lg hover:bg-accent relative"
:disabled="!pathHistory.backAllowed()" :disabled="!pathHistory.backAllowed()"
@ -53,7 +53,11 @@
>{{ item }}</div> >{{ item }}</div>
</div> </div>
</button> </button>
<button class="p-2 rounded-lg hover:bg-accent" @click="up()"> <button
class="p-2 rounded-lg hover:bg-accent"
@click="up()"
:disabled="pathHistory.current() === '/'"
>
<ArrowUpIcon class="size-icon icon-default" /> <ArrowUpIcon class="size-icon icon-default" />
</button> </button>
<button class="p-2 rounded-lg hover:bg-accent" @click="directoryViewRef.getEntries()"> <button class="p-2 rounded-lg hover:bg-accent" @click="directoryViewRef.getEntries()">
@ -64,7 +68,7 @@
<div <div
class="p-1 md:px-4 md:py-2 col-start-1 col-end-3 row-start-2 row-end-3 md:col-start-auto md:col-end-auto md:row-start-auto md:row-end-auto" class="p-1 md:px-4 md:py-2 col-start-1 col-end-3 row-start-2 row-end-3 md:col-start-auto md:col-end-auto md:row-start-auto md:row-end-auto"
> >
<PathBreadCrumbs :path="pathHistory.current() ?? '/'" @cd="newPath => cd(newPath, newPath !== pathHistory.current())" /> <PathBreadCrumbs :path="pathHistory.current() ?? '/'" @cd="newPath => cd(newPath)" />
</div> </div>
<div class="p-1 md:px-4 md:py-2"> <div class="p-1 md:px-4 md:py-2">
@ -83,10 +87,10 @@
</div> </div>
<div class="grow overflow-hidden"> <div class="grow overflow-hidden">
<DirectoryView <DirectoryView
:path="pathHistory.current() ?? '/'" :path="pathHistory.current()"
:searchFilterRegExp="searchFilterRegExp" :searchFilterRegExp="searchFilterRegExp"
@cd="newPath => cd(newPath)" @cd="newPath => cd(newPath)"
@edit="(...args) => console.log('edit', ...args)" @edit="openEditor"
@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"
/> />
@ -97,11 +101,10 @@
<script> <script>
import { inject, ref, reactive, watch, nextTick } from 'vue'; import { inject, ref, reactive, watch, nextTick } from 'vue';
import { useRoute } from "vue-router"; import { useRoute, useRouter } from "vue-router";
import DirectoryView from "../components/DirectoryView.vue"; import DirectoryView from "../components/DirectoryView.vue";
import { errorStringHTML, canonicalPath } from '@45drives/cockpit-helpers'; import { useSpawn, errorString, errorStringHTML, canonicalPath } from '@45drives/cockpit-helpers';
import PathBreadCrumbs from '../components/PathBreadCrumbs.vue'; import PathBreadCrumbs from '../components/PathBreadCrumbs.vue';
import { checkIfExists, checkIfAllowed } from '../mode';
import { notificationsInjectionKey, pathHistoryInjectionKey, lastPathStorageKey, settingsInjectionKey } from '../keys'; import { notificationsInjectionKey, pathHistoryInjectionKey, lastPathStorageKey, settingsInjectionKey } from '../keys';
import { ArrowLeftIcon, ArrowRightIcon, ArrowUpIcon, RefreshIcon, ChevronDownIcon, SearchIcon } from '@heroicons/vue/solid'; import { ArrowLeftIcon, ArrowRightIcon, ArrowUpIcon, RefreshIcon, ChevronDownIcon, SearchIcon } from '@heroicons/vue/solid';
@ -110,6 +113,7 @@ export default {
const settings = inject(settingsInjectionKey); const settings = inject(settingsInjectionKey);
const notifications = inject(notificationsInjectionKey); const notifications = inject(notificationsInjectionKey);
const route = useRoute(); const route = useRoute();
const router = useRouter();
const pathHistory = inject(pathHistoryInjectionKey); const pathHistory = inject(pathHistoryInjectionKey);
const directoryViewRef = ref(); const directoryViewRef = ref();
const searchFilterStr = ref(""); const searchFilterStr = ref("");
@ -143,66 +147,63 @@ export default {
} }
}); });
const cd = (newPath, saveHistory = true) => { const cd = (newPath) => {
localStorage.setItem(lastPathStorageKey, newPath); router.push(`/browse${newPath}`);
if (saveHistory)
pathHistory.push(newPath);
cockpit.location.go(`/browse${newPath}`);
}; };
const back = (saveHistory = false) => { const back = () => {
cd(pathHistory.back() ?? '/', saveHistory); cd(pathHistory.back() ?? '/');
} }
const forward = (saveHistory = false) => { const forward = () => {
cd(pathHistory.forward() ?? '/', saveHistory); cd(pathHistory.forward() ?? '/');
} }
const up = (saveHistory = true) => { const up = () => {
cd(canonicalPath((pathHistory.current() ?? "") + '/..'), saveHistory); cd((pathHistory.current() ?? "") + '/..');
}
const openEditor = (path) => {
router.push(`/edit${path}`);
} }
watch(searchFilterStr, () => { watch(searchFilterStr, () => {
searchFilterRegExp.value = new RegExp( searchFilterRegExp.value = new RegExp(
`^${ `^${searchFilterStr.value
searchFilterStr.value .replace(/[.+^${}()|[\]]|\\(?![*?])/g, '\\$&') // escape special chars, \\ only if not before * or ?
.replace(/[.+^${}()|[\]]|\\(?![*?])/g, '\\$&') // escape special chars, \\ only if not before * or ? .replace(/(?<!\\)\*/g, '.*') // replace * with .* if not escaped
.replace(/(?<!\\)\*/g, '.*') // replace * with .* if not escaped .replace(/(?<!\\)\?/g, '.') // replace ? with . if not escaped
.replace(/(?<!\\)\?/g, '.') // replace ? with . if not escaped
}` }`
); );
}, { immediate: true }); }, { immediate: true });
watch(() => route.params.path, async () => { watch(() => route.params.path, async (current, last) => {
if (!route.params.path) if (!last) {
return cockpit.location.go('/browse/'); console.log("First watch execute", last);
const tmpPath = canonicalPath(route.params.path);
if (pathHistory.current() !== tmpPath) {
pathHistory.push(tmpPath);
} }
if (route.name !== 'browse' || current === last)
return;
try { try {
let badPath = false; const tmpPath = route.params.path;
if (!await checkIfExists(tmpPath)) { // let realPath = (await useSpawn(['realpath', '--canonicalize-existing', tmpPath], { superuser: 'try' }).promise()).stdout.trim();
notifications.value.constructNotification("Failed to open path", `${tmpPath} does not exist.`, 'error'); // if (tmpPath !== realPath)
badPath = true; // return cd(realPath);
} else if (!await checkIfAllowed(tmpPath, true)) { try {
notifications.value.constructNotification("Failed to open path", `Permission denied for ${tmpPath}`, 'denied'); await useSpawn(['test', '-r', tmpPath, '-a', '-x', tmpPath], { superuser: 'try' }).promise();
badPath = true; } catch (error) {
console.error(error);
throw new Error(`Permission denied for ${tmpPath}`);
} }
if (badPath) { localStorage.setItem(lastPathStorageKey, tmpPath);
if (pathHistory.backAllowed()) if (pathHistory.current() !== tmpPath) {
back(); pathHistory.push(tmpPath); // updates actual view
else
up(false);
} else {
localStorage.setItem(lastPathStorageKey, tmpPath);
} }
} catch (error) { } catch (error) {
notifications.value.constructNotification("Failed to open path", errorStringHTML(error), 'error'); notifications.value.constructNotification("Failed to open path", errorStringHTML(error), 'error');
if (pathHistory.backAllowed()) if (pathHistory.backAllowed())
back(); back();
else else
up(false); up();
} }
}, { immediate: true }); }, { immediate: true });
@ -219,6 +220,7 @@ export default {
back, back,
forward, forward,
up, up,
openEditor,
} }
}, },
components: { components: {

View File

@ -0,0 +1,22 @@
<template>
<div class="grow">Editing {{ path }}</div>
</template>
<script>
import { ref, watch } from "vue";
import { useRoute } from "vue-router";
export default {
setup() {
const path = ref("");
const route = useRoute();
watch(() => route.params.path, (filePath) => path.value = filePath, { immediate: true });
return {
path,
}
}
}
</script>

View File

@ -0,0 +1,42 @@
<template>
<div class="grow flex flex-col items-center justify-center gap-10">
<div class="text-header">{{title}}</div>
<div class="text-muted">{{message}}</div>
<div class="text-muted">Redirecting in {{counter}}...</div>
</div>
</template>
<script setup>
import { ref } from 'vue';
import { useRouter } from 'vue-router';
import { lastPathStorageKey } from '../keys';
const props = defineProps({
message: {
type: String,
required: false,
default: "A system error has occured.",
},
title: {
type: String,
required: false,
default: "Error",
}
})
const router = useRouter();
const counter = ref(3);
const countdown = () => {
counter.value--;
if (counter.value > 0)
setTimeout(countdown, 1000);
else {
const lastLocation = localStorage.getItem(lastPathStorageKey) ?? '/';
router.push(`/browse${lastLocation}`);
}
}
setTimeout(countdown, 1000);
</script>