Rudimentary text file editing support.

This commit is contained in:
2026-05-06 00:20:19 +00:00
parent e979d679b2
commit 07daf372e8
10 changed files with 370 additions and 35 deletions
+62 -9
View File
@@ -11,11 +11,27 @@
<AboutModal />
<AccessDeniedModal />
<header>
<HeaderMain ref="headerMain" :path="path.pathList" :query="path.query" />
<BreadCrumb :path="path.pathList" primary />
<HeaderMain
ref="headerMain"
:path="path.pathList"
:query="path.query"
:editor-mode="path.isEditorPath"
/>
<BreadCrumb
:path="path.breadcrumbPathList"
:links="path.breadcrumbLinks"
primary
/>
</header>
<main>
<RouterView :path="path.pathList" :query="path.query" />
<main class="transition-wrapper">
<Transition
:name="routeTransitionName"
@after-enter="store.transitionDirection = 'none'"
>
<div :key="routeViewKey" class="explorer-content">
<RouterView :path="path.pathList" :query="path.query" />
</div>
</Transition>
</main>
<footer v-if="store.selected.size || store.uprogress.total || store.dprogress.total">
<SelectionToolbar :path="path.pathList" />
@@ -44,20 +60,49 @@ import type { SortOrder } from './utils/docsort'
interface Path {
path: string
isEditorPath: boolean
pathList: string[]
breadcrumbPathList: string[]
breadcrumbLinks?: string[]
query: string
}
const store = useMainStore()
const path: ComputedRef<Path> = computed(() => {
const p = decodeURIComponent(Router.currentRoute.value.path).split('//')
const pathList = (p[0] ?? '').split('/').filter(value => value !== '')
const routePathList = (p[0] ?? '').split('/').filter(value => value !== '')
const query = p.slice(1).join('//')
const isEditorPath = routePathList[0] === 'edit'
const pathList = isEditorPath ? routePathList.slice(1, -1) : routePathList
const breadcrumbPathList = isEditorPath
? routePathList.slice(1)
: routePathList
const breadcrumbLinks = isEditorPath
? [
'/',
...routePathList
.slice(1, -1)
.map((_, index) => `/${routePathList.slice(1, index + 2).join('/')}/`),
`/${routePathList.join('/')}`
]
: undefined
return {
path: p[0] ?? '',
isEditorPath,
pathList,
breadcrumbPathList,
breadcrumbLinks,
query
}
})
const routeTransitionName = computed(() => {
if (store.transitionDirection === 'forward') return 'slide-forward'
if (store.transitionDirection === 'backward') return 'slide-backward'
return ''
})
const routeViewKey = computed(() => {
const route = Router.currentRoute.value
return route.name === 'editor' ? route.path : String(route.name ?? route.path)
})
watch(
() => path.value.path,
() => {
@@ -86,7 +131,7 @@ const globalShortcutHandler = (event: KeyboardEvent) => {
const fileExplorer = store.fileExplorer as any
if (!fileExplorer) return
const c = fileExplorer.isCursor()
const input = (event.target as HTMLElement).tagName === 'INPUT'
const input = ['INPUT', 'TEXTAREA'].includes((event.target as HTMLElement).tagName)
const keyup = event.type === 'keyup'
// Always clear repeat timer on arrow keyup, even if focus moved to input
@@ -142,11 +187,17 @@ const globalShortcutHandler = (event: KeyboardEvent) => {
// Paging/navigation key handling - fall through to bottom
}
// Find: process on keydown so that we can bypass the built-in search hotkey
else if (!keyup && event.key === 'f' && (event.ctrlKey || event.metaKey)) {
else if (
!path.value.isEditorPath &&
!input &&
!keyup &&
event.key === 'f' &&
(event.ctrlKey || event.metaKey)
) {
headerMain.value!.toggleSearchInput()
}
// Search also on / (UNIX style) - use code to support any keyboard layout
else if (!input && keyup && event.code === 'Slash') {
else if (!path.value.isEditorPath && !input && keyup && event.code === 'Slash') {
// Record the actual character for display (varies by keyboard layout)
if (event.key.length === 1 && event.key !== store.prefs.searchHotkey) {
store.prefs.searchHotkey = event.key
@@ -159,7 +210,9 @@ const globalShortcutHandler = (event: KeyboardEvent) => {
store.clearToast()
// Keep rename and other non-search inputs isolated from search behavior.
if (input && !searchInput) return
headerMain.value!.clearSearch(event)
if (!path.value.isEditorPath) {
headerMain.value!.clearSearch(event)
}
store.focusBreadcrumb()
} else if (!input && keyup && event.key === 'Backspace') {
Router.back()
+24 -4
View File
@@ -8,7 +8,7 @@
@focus=focusCurrent
tabindex=0
>
<a href="#/"
<a :href="`/#${urlAt(0)}`"
:ref="el => setLinkRef(0, el)"
class="home"
:class="{ current: !!isCurrent(0) }"
@@ -22,7 +22,7 @@
<CursorTooltip ref="homeTooltip" text="/">/</CursorTooltip>
</a>
<template v-for="(location, index) in longest" :key="index">
<a :href="`/#/${longest.slice(0, index + 1).join('/')}/`"
<a :href="`/#${urlAt(index + 1)}`"
:class="{ current: !!isCurrent(index + 1) }"
:aria-current="isCurrent(index + 1)"
@click.prevent="navigate(index + 1)"
@@ -62,10 +62,17 @@ const setPathTooltipRef = (index: number, el: any) => {
const props = defineProps<{
path: Array<string>
links?: Array<string>
primary?: boolean
}>()
const longest = ref<Array<string>>([])
const longestLinks = ref<Array<string>>(['/'])
const defaultLinks = (segments: Array<string>) => [
'/',
...segments.map((_, index) => `/${segments.slice(0, index + 1).join('/')}/`)
]
const isCurrent = (index: number) =>
index == props.path.length ? 'location' : undefined
@@ -77,16 +84,22 @@ const focusCurrent = () => {
})
}
const urlAt = (index: number) => {
const explicit = longestLinks.value[index]
return explicit ?? (index ? `/${longest.value.slice(0, index).join('/')}/` : '/')
}
const navigate = (index: number) => {
const link = links[index]
if (!link) throw Error(`No link at index ${index} (path: ${props.path})`)
const url = index ? `/${longest.value.slice(0, index).join('/')}/` : '/'
const url = urlAt(index)
const long = longest.value.length ? `/${longest.value.join('/')}/` : '/'
const browser = decodeURIComponent(location.hash.slice(1).split('//')[0] ?? '')
const u = url.replaceAll('?', '%3F').replaceAll('#', '%23')
// Clicking on current link clears the rest of the path and adds new history
if (isCurrent(index)) {
longest.value.splice(index)
longestLinks.value.splice(index + 1)
router.push(u)
}
// Moving along breadcrumbs doesn't create new history
@@ -102,20 +115,27 @@ const move = (dir: number) => {
}
watchEffect(() => {
const currentLinks = props.links ?? defaultLinks(props.path)
const longcut = longest.value.slice(0, props.path.length)
const same = longcut.every((value, index) => value === props.path[index])
// Navigated out of previous path, reset longest to current
if (!same) longest.value = props.path
if (!same) {
longest.value = props.path
longestLinks.value = currentLinks
}
else if (props.path.length > longcut.length) {
longest.value = longcut.concat(props.path.slice(longcut.length))
longestLinks.value.splice(0, currentLinks.length, ...currentLinks)
} else {
// Prune deleted folders from longest
for (let i = props.path.length; i < longest.value.length; ++i) {
if (!exists(longest.value.slice(0, i + 1))) {
longest.value = longest.value.slice(0, i)
longestLinks.value = longestLinks.value.slice(0, i + 1)
break
}
}
longestLinks.value.splice(0, currentLinks.length, ...currentLinks)
}
// If needed, focus primary navigation to new location
if (props.primary)
+1 -1
View File
@@ -49,7 +49,7 @@
<FileRenameInput :doc="doc" :rename="rename" :exit="() => {editing = null}" />
</template>
<template v-else>
<a :href=doc.url tabindex=-1 @contextmenu.stop @focus.stop="store.cursor = doc.key">
<a :href="doc.text ? doc.editurl : doc.url" tabindex=-1 @contextmenu.stop @focus.stop="store.cursor = doc.key">
{{ doc.name }}
</a>
<button tabindex=-1 v-if="store.cursor == doc.key" class="rename-button" @click="() => (editing = doc)">🖊</button>
+8 -1
View File
@@ -49,10 +49,12 @@ import { Doc } from '@/repositories/Document'
import { useMainStore } from '@/stores/main'
import { formatSize } from '@/utils'
import { computed, ref } from 'vue'
import { useRouter } from 'vue-router'
import CursorTooltip from './CursorTooltip.vue'
import SparseIndicator from './SparseIndicator.vue'
const store = useMainStore()
const router = useRouter()
type EditingProp = {
rename: (doc: Doc, newName: string) => void
exit: () => void
@@ -87,7 +89,12 @@ const snap = computed(() => {
})
const onclick = (ev: Event) => {
if (m.value!.play()) ev.preventDefault()
if (m.value!.play()) {
ev.preventDefault()
} else if (props.doc.text) {
ev.preventDefault()
router.push(props.doc.editurl.replace('/#', ''))
}
store.cursor = props.doc.key
}
</script>
+28 -19
View File
@@ -1,30 +1,38 @@
<template>
<nav class="headermain buttons">
<UploadButton :path="props.path" />
<SvgButton
name="create-folder"
tooltip="New folder"
@click="() => { store.fileExplorer!.newFolder() }"
/>
<div class="smallgap"></div>
<SvgButton name="eye" @click="store.prefs.gallery = !store.prefs.gallery" tooltip="Details/Gallery" />
<div class="search-group">
<SvgButton name="find" tabindex="-1" @click="focusSearch" tooltip="Search" />
<input
ref="search"
type="search"
:value="query"
@input="updateSearch"
@keydown.escape="clearSearch"
<template v-if="!props.editorMode">
<UploadButton :path="props.path" />
<SvgButton
name="create-folder"
tooltip="New folder"
@click="() => { store.fileExplorer!.newFolder() }"
/>
<span v-if="!query" class="search-hint" @click="focusSearch">{{ store.prefs.searchHotkey }}</span>
</div>
<div v-if="showSortHints" class="sort-hints">
<div class="smallgap"></div>
<SvgButton name="eye" @click="store.prefs.gallery = !store.prefs.gallery" tooltip="Details/Gallery" />
<div class="search-group">
<SvgButton name="find" tabindex="-1" @click="focusSearch" tooltip="Search" />
<input
ref="search"
type="search"
:value="query"
@input="updateSearch"
@keydown.escape="clearSearch"
/>
<span v-if="!query" class="search-hint" @click="focusSearch">{{ store.prefs.searchHotkey }}</span>
</div>
</template>
<div v-if="!props.editorMode && showSortHints" class="sort-hints">
<span class="sort-label">Order</span>
<span class="keycap">1</span>
<span class="keycap">2</span>
<span class="keycap">3</span>
</div>
<SvgButton
v-if="props.editorMode"
name="disk"
tooltip="Save (Ctrl/Cmd+S)"
@click="store.editorSave?.()"
/>
<div class="spacer smallgap"></div>
<DiskSpace v-if="store.space.disk" />
<SvgButton name="cog" @click="settingsMenu" />
@@ -49,6 +57,7 @@ const textInputFocused = ref(false)
const props = defineProps<{
path: Array<string>
query: string
editorMode?: boolean
}>()
const isInputElement = (el: Element | null): boolean => {
+8
View File
@@ -89,6 +89,14 @@ export class Doc {
get print(): boolean {
return (FILE_TYPES.print as readonly string[]).includes(this.ext)
}
get text(): boolean {
return (FILE_TYPES.text as readonly string[]).includes(this.ext)
}
get editurl(): string {
if (!this.text) return ''
const p = this.loc ? `${this.loc}/${this.name}` : this.name
return '/#/edit/' + p.replaceAll('?', '%3F').replaceAll('#', '%23')
}
get complete(): boolean {
return !this.ghost && (this.dir || this.size <= this.allocated)
}
+6
View File
@@ -1,5 +1,6 @@
import { useMainStore } from '@/stores/main'
import ExplorerView from '@/views/ExplorerView.vue'
import TextEditorView from '@/views/TextEditorView.vue'
import { createRouter, createWebHashHistory } from 'vue-router'
function getPathDepth(path: string): number {
@@ -10,6 +11,11 @@ function getPathDepth(path: string): number {
const router = createRouter({
history: createWebHashHistory(import.meta.env.BASE_URL),
routes: [
{
path: '/edit/:pathMatch(.*)*',
name: 'editor',
component: TextEditorView
},
{
path: '/:pathMatch(.*)*',
name: 'explorer',
+2
View File
@@ -99,6 +99,7 @@ export const useMainStore = defineStore('main', {
isLoggedIn: false as boolean
},
transitionDirection: 'none' as 'forward' | 'backward' | 'none',
editorSave: null as null | (() => void),
space: {
disk: 0,
free: 0,
@@ -327,6 +328,7 @@ export const useMainStore = defineStore('main', {
this.connected = false
this.dialog = ''
this.cursor = ''
this.editorSave = null
},
async logout() {
console.log('Logout')
+59 -1
View File
@@ -77,7 +77,65 @@ export const FILE_TYPES = {
imageBrowser: ['avif', 'gif', 'jpg', 'jpeg', 'png', 'webp', 'svg'],
// Images that require server-side preview (browsers cannot display them natively)
image: ['bmp', 'heic', 'heif', 'ico', 'tif', 'tiff'],
print: ['epub', 'mobi', 'pdf']
print: ['epub', 'mobi', 'pdf'],
text: [
'txt',
'md',
'json',
'xml',
'yaml',
'yml',
'toml',
'ini',
'conf',
'config',
'cfg',
'log',
'csv',
'tsv',
'py',
'js',
'ts',
'jsx',
'tsx',
'html',
'htm',
'css',
'scss',
'sass',
'less',
'vue',
'php',
'rb',
'go',
'rs',
'java',
'c',
'cpp',
'h',
'hpp',
'cs',
'swift',
'kt',
'sh',
'bash',
'zsh',
'fish',
'ps1',
'bat',
'cmd',
'sql',
'lua',
'r',
'pl',
'dockerfile',
'makefile',
'gitignore',
'gitattributes',
'env',
'diff',
'patch'
]
} as const
export type FileCategory = keyof typeof FILE_TYPES
+172
View File
@@ -0,0 +1,172 @@
<template>
<div class="text-editor">
<div class="editor-body">
<div v-if="loading" class="status">Loading</div>
<div v-else-if="error" class="status error">{{ error }}</div>
<textarea
v-else
ref="textarea"
v-model="content"
spellcheck="false"
@keydown="onKeydown"
/>
</div>
</div>
</template>
<script setup lang="ts">
import { apiFetch } from '@/repositories/Client'
import { useMainStore } from '@/stores/main'
import { computed, onMounted, onUnmounted, ref } from 'vue'
import { onBeforeRouteLeave, useRoute } from 'vue-router'
const route = useRoute()
const store = useMainStore()
const MAX_SIZE = 1024 * 1024 // 1 MiB
const filePath = computed(() => decodeURIComponent(route.path.slice(6))) // strip /edit/
const filename = computed(() => filePath.value.split('/').pop() || '')
const filesUrl = computed(() => {
return (
'/files/' +
filePath.value
.split('/')
.map(part => encodeURIComponent(part))
.join('/')
)
})
const content = ref('')
const original = ref('')
const loading = ref(true)
const saving = ref(false)
const error = ref('')
const textarea = ref<HTMLTextAreaElement | null>(null)
const dirty = computed(() => content.value !== original.value)
onBeforeRouteLeave((_to, _from, next) => {
if (!dirty.value) {
next()
return
}
const discard = window.confirm('You have unsaved changes. Discard them?')
next(discard)
})
const beforeUnload = (event: BeforeUnloadEvent) => {
if (!dirty.value) return
event.preventDefault()
event.returnValue = ''
}
const onKeydown = (ev: KeyboardEvent) => {
if ((ev.ctrlKey || ev.metaKey) && ev.key === 's') {
ev.preventDefault()
save()
}
}
const save = async () => {
if (saving.value || loading.value) return
saving.value = true
try {
const res = await apiFetch(filesUrl.value, {
method: 'PUT',
body: content.value,
headers: { 'Content-Type': 'text/plain; charset=utf-8' }
})
if (!res.ok) {
const data = await res.json().catch(() => ({}))
throw new Error(data.message || data.detail || `${res.status} ${res.statusText}`)
}
original.value = content.value
store.showToast(`Saved ${filename.value}`)
} catch (err) {
console.error('Save failed', err)
store.showToast(err instanceof Error ? err.message : 'Save failed')
} finally {
saving.value = false
}
}
onMounted(async () => {
store.editorSave = save
window.addEventListener('beforeunload', beforeUnload)
loading.value = true
error.value = ''
try {
const res = await fetch(filesUrl.value, { method: 'HEAD' })
if (!res.ok) throw new Error(`${res.status} ${res.statusText}`)
const size = Number(res.headers.get('content-length') || '0')
if (size > MAX_SIZE) {
throw new Error(
`File is too large to edit (${(size / 1024 / 1024).toFixed(1)} MB)`
)
}
const textRes = await fetch(filesUrl.value)
if (!textRes.ok) throw new Error(`${textRes.status} ${textRes.statusText}`)
const text = await textRes.text()
content.value = text
original.value = text
} catch (err) {
error.value = err instanceof Error ? err.message : 'Failed to load file'
} finally {
loading.value = false
}
})
onUnmounted(() => {
if (store.editorSave === save) {
store.editorSave = null
}
window.removeEventListener('beforeunload', beforeUnload)
})
</script>
<style scoped>
.text-editor {
display: flex;
flex-direction: column;
height: 100%;
background: #1a1a1a;
color: #ddd;
}
.editor-body {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
}
.editor-body textarea {
flex: 1;
width: 100%;
resize: none;
border: none;
outline: none;
padding: 1rem;
font-family: ui-monospace, SFMono-Regular, 'SF Mono', Menlo, Consolas, monospace;
font-size: 0.875rem;
line-height: 1.5;
background: #1a1a1a;
color: #ddd;
white-space: pre;
overflow-wrap: normal;
overflow-x: auto;
}
.editor-body textarea::selection {
background: var(--accent-color, #007bff);
color: #000;
}
.status {
padding: 2rem;
text-align: center;
font-size: 1rem;
color: #888;
}
.status.error {
color: #f55;
}
</style>