diff --git a/navigator-vue/src/components/DirectoryEntry.vue b/navigator-vue/src/components/DirectoryEntry.vue index 1667c11..389322d 100644 --- a/navigator-vue/src/components/DirectoryEntry.vue +++ b/navigator-vue/src/components/DirectoryEntry.vue @@ -7,7 +7,7 @@ - showEntries = !showEntries"> + @@ -107,6 +107,14 @@ export default { return directoryViewRef.value?.getEntries?.(); } + const toggleShowEntries = () => { + emit('startProcessing'); + nextTick(() => { + showEntries.value = !showEntries.value; + nextTick(() => emit('stopProcessing')); + }) + } + watch(props.entry, () => { if (props.entry.type === 'directory' || (props.entry.type === 'symbolic link' && props.entry.target?.type === 'directory')) { icon.value = FolderIcon; @@ -125,6 +133,7 @@ export default { directoryViewRef, doubleClickCallback, getEntries, + toggleShowEntries, DirectoryEntryList, nextTick, } diff --git a/navigator-vue/src/components/DirectoryEntryList.vue b/navigator-vue/src/components/DirectoryEntryList.vue index 4445119..5f30760 100644 --- a/navigator-vue/src/components/DirectoryEntryList.vue +++ b/navigator-vue/src/components/DirectoryEntryList.vue @@ -90,30 +90,40 @@ export default { } const getEntries = async () => { - processingHandler.start(); - const readLink = (target, cwd, symlinkStr) => { - 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); - }) + if (!props.path) { + return; } + processingHandler.start(); const US = '\x1F'; 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 { const cwd = props.path; const procs = []; + let tmpEntries; procs.push(...entryRefs.value.filter(entryRef => entryRef.showEntries).map(entryRef => entryRef.getEntries())); const entryNames = (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 { return JSON.parse(escaped); } 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; } }) @@ -141,7 +151,7 @@ export default { '%F', // type '%N', // quoted name with symlink ] - entries.value = + tmpEntries = entryNames.length ? (await useSpawn(['stat', `--printf=${fields.join(US)}${RS}`, ...entryNames], { superuser: 'try', directory: cwd }).promise()).stdout .split(RS) @@ -154,7 +164,7 @@ export default { mode = parseInt(mode, 16); const entry = reactive({ name, - path: canonicalPath(`/${cwd}/${name}`), + path: `${cwd}/${name}`.replace(/\/+/g, '/'), mode, modeStr, size, @@ -167,8 +177,10 @@ export default { type, target: {}, }); - if (type === 'symbolic link') - procs.push(readLink(entry.target, cwd, symlinkStr)); + if (type === 'symbolic link') { + entry.target.rawPath = symlinkStr.split(/\s*->\s*/)[1].trim().replace(/^['"]|['"]$/g, ''); + entry.target.path = entry.target.rawPath.replace(/^(?!\/)/, `${cwd}/`); + } return entry; } catch (error) { console.error(errorString(error)); @@ -176,10 +188,13 @@ export default { } }).filter(entry => entry !== null) : []; + procs.push(processLinks(tmpEntries.filter(entry => entry.type === 'symbolic link').map(entry => entry.target))); processingHandler.start(); - console.log("resolving", procs.length, 'symlinks'); return Promise.all(procs) .then(() => { + if (props.path !== cwd) + return; + entries.value = [...tmpEntries]; emitStats(); sortEntries(); }) @@ -223,7 +238,11 @@ export default { watch(() => props.sortCallback, 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 { settings, diff --git a/navigator-vue/src/components/PathBreadCrumbs.vue b/navigator-vue/src/components/PathBreadCrumbs.vue index e97fa81..26198a4 100644 --- a/navigator-vue/src/components/PathBreadCrumbs.vue +++ b/navigator-vue/src/components/PathBreadCrumbs.vue @@ -5,7 +5,7 @@ {{ segment }} diff --git a/navigator-vue/src/main.js b/navigator-vue/src/main.js index 36a7289..b7d683f 100644 --- a/navigator-vue/src/main.js +++ b/navigator-vue/src/main.js @@ -1,16 +1,17 @@ import { createApp, reactive } from '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 { useSpawn, errorStringHTML } from '@45drives/cockpit-helpers'; import router from './router'; const notificationFIFO = reactive(new FIFO()); -const errorHandler = (error) => { +const errorHandler = (error, title = "System Error") => { console.error(error); const notificationObj = { - title: "System Error", + title, body: "", show: true, timeout: 10000, @@ -32,6 +33,33 @@ const errorHandler = (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); app.config.errorHandler = (error) => errorHandler(error); diff --git a/navigator-vue/src/router/index.js b/navigator-vue/src/router/index.js index 427bf56..407f8ec 100644 --- a/navigator-vue/src/router/index.js +++ b/navigator-vue/src/router/index.js @@ -1,21 +1,27 @@ import { createRouter, createWebHashHistory } from 'vue-router'; -import { lastPathStorageKey } from '../keys'; const routes = [ { - path: '/browse:path(.+)', + path: '/browse:path(/.*)?', name: 'browse', component: () => import('../views/Browser.vue'), }, { - path: '/edit/:path(.*)', + path: '/edit:path(/.+)', name: 'edit', component: () => import('../views/Editor.vue'), }, { - path: '/:pathMatch(.*)*', - name: 'redirectToBrowse', + path: '/errorRedirect', + 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({ @@ -23,14 +29,4 @@ const router = createRouter({ 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; diff --git a/navigator-vue/src/views/Browser.vue b/navigator-vue/src/views/Browser.vue index d7f1d14..dd426c4 100644 --- a/navigator-vue/src/views/Browser.vue +++ b/navigator-vue/src/views/Browser.vue @@ -4,7 +4,7 @@ - + {{ item }} - + @@ -64,7 +68,7 @@ - cd(newPath, newPath !== pathHistory.current())" /> + cd(newPath)" /> @@ -83,10 +87,10 @@ 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'}`)" ref="directoryViewRef" /> @@ -97,11 +101,10 @@ diff --git a/navigator-vue/src/views/ErrorRedirect.vue b/navigator-vue/src/views/ErrorRedirect.vue new file mode 100644 index 0000000..bd18288 --- /dev/null +++ b/navigator-vue/src/views/ErrorRedirect.vue @@ -0,0 +1,42 @@ + + + {{title}} + {{message}} + Redirecting in {{counter}}... + + + +