frontend: add biome checks and pre-commit integration (excluding preview files)

This commit is contained in:
2026-04-26 06:43:06 +00:00
parent 942b54d795
commit eb5ff82de6
42 changed files with 1016 additions and 504 deletions
+1
View File
@@ -1,6 +1,7 @@
.*
*.lock
!.gitignore
!.pre-commit-config.yaml
__pycache__/
*.egg-info/
/cista/_version.py
+28
View File
@@ -0,0 +1,28 @@
repos:
- repo: local
hooks:
- id: ruff-check
name: ruff check
entry: uv run ruff check .
language: system
pass_filenames: false
- id: ruff-format-check
name: ruff format check
entry: uv run ruff format --check .
language: system
pass_filenames: false
- id: pytest
name: pytest
entry: uv run pytest
language: system
pass_filenames: false
- id: frontend-type-check
name: frontend type-check
entry: npm --prefix frontend run type-check
language: system
pass_filenames: false
- id: frontend-biome-check
name: frontend biome check
entry: npm --prefix frontend run check
language: system
pass_filenames: false
+69
View File
@@ -0,0 +1,69 @@
{
"$schema": "https://biomejs.dev/schemas/1.9.4/schema.json",
"files": {
"ignore": ["node_modules", "dist", "coverage", "components.d.ts"]
},
"formatter": {
"enabled": true,
"indentStyle": "space",
"indentWidth": 2,
"lineWidth": 88
},
"linter": {
"enabled": true,
"rules": {
"recommended": true,
"style": {
"noInferrableTypes": "off",
"noNonNullAssertion": "off",
"noParameterAssign": "off",
"noUselessElse": "off",
"useExponentiationOperator": "off",
"useSingleVarDeclarator": "off",
"useTemplate": "off",
"useConst": "off",
"useImportType": "off"
},
"suspicious": {
"noAssignInExpressions": "off",
"noDoubleEquals": "off",
"noExplicitAny": "off",
"noImplicitAnyLet": "off",
"noMisleadingCharacterClass": "off"
},
"complexity": {
"noBannedTypes": "off",
"useOptionalChain": "off"
},
"correctness": {
"noSwitchDeclarations": "off"
},
"a11y": {
"useGenericFontNames": "off"
}
}
},
"overrides": [
{
"include": ["**/*.d.ts"],
"linter": {
"rules": {
"suspicious": {
"noExplicitAny": "off"
},
"complexity": {
"noBannedTypes": "off"
}
}
}
}
],
"javascript": {
"formatter": {
"quoteStyle": "single",
"semicolons": "asNeeded",
"trailingCommas": "none",
"arrowParentheses": "asNeeded"
}
}
}
+5 -17
View File
@@ -9,8 +9,10 @@
"test:unit": "vitest",
"build-only": "vite build",
"type-check": "vue-tsc --noEmit -p tsconfig.vitest.json --composite false",
"lint": "eslint . --ext .vue,.js,.jsx,.cjs,.mjs,.ts,.tsx,.cts,.mts --fix --ignore-path .gitignore",
"format": "prettier --write src/"
"lint": "biome lint .",
"format": "biome format --write .",
"format:check": "biome format --check .",
"check": "biome check ."
},
"engines": {
"node": ">=18.0.0"
@@ -30,33 +32,19 @@
"vue-router": "^5.0.1"
},
"devDependencies": {
"@rushstack/eslint-patch": "^1.15.0",
"@biomejs/biome": "^1.9.4",
"@tsconfig/node18": "^18.2.6",
"@types/jsdom": "^27.0.0",
"@types/lodash-es": "^4.17.12",
"@types/node": "^25.1.0",
"@vitejs/plugin-vue": "^6.0.3",
"@vue/eslint-config-prettier": "^10.2.0",
"@vue/eslint-config-typescript": "^14.6.0",
"@vue/test-utils": "^2.4.6",
"@vue/tsconfig": "^0.8.1",
"babel-eslint": "^10.1.0",
"eslint": "^9.39.2",
"eslint-plugin-vue": "^10.7.0",
"jsdom": "^27.4.0",
"npm-run-all2": "^8.0.4",
"prettier": "^3.8.1",
"typescript": "~5.9.3",
"vite": "^7.3.1",
"vitest": "^4.0.18",
"vue-tsc": "^3.2.4"
},
"prettier": {
"semi": false,
"singleQuote": true,
"trailingComma": "none",
"arrowParens": "avoid",
"endOfLine": "lf",
"printWidth": 88
}
}
+73 -33
View File
@@ -24,21 +24,21 @@
</template>
<script setup lang="ts">
import { RouterView } from 'vue-router'
import type { ComputedRef } from 'vue'
import type HeaderMain from '@/components/HeaderMain.vue'
import { onMounted, onUnmounted, ref, watchEffect } from 'vue'
import { loadSession, watchConnect, watchDisconnect } from '@/repositories/WS'
import { useMainStore } from '@/stores/main'
import type { ComputedRef } from 'vue'
import { onMounted, onUnmounted, ref, watchEffect } from 'vue'
import { RouterView } from 'vue-router'
import { computed } from 'vue'
import Router from '@/router/index'
import type { SortOrder } from './utils/docsort'
import { computed } from 'vue'
import AccessDeniedModal from './components/AccessDeniedModal.vue'
import SelectionToolbar from './components/SelectionToolbar.vue'
import type SettingsModalVue from './components/SettingsModal.vue'
import UserManagementModal from './components/UserManagementModal.vue'
import UserTokensModal from './components/UserTokensModal.vue'
import AccessDeniedModal from './components/AccessDeniedModal.vue'
import SelectionToolbar from './components/SelectionToolbar.vue'
import type { SortOrder } from './utils/docsort'
interface Path {
path: string
@@ -57,7 +57,10 @@ const path: ComputedRef<Path> = computed(() => {
}
})
watchEffect(() => {
document.title = path.value.path.replace(/\/$/, '').split('/').pop() || store.server.name || 'Cista Storage'
document.title =
path.value.path.replace(/\/$/, '').split('/').pop() ||
store.server.name ||
'Cista Storage'
})
onMounted(loadSession)
onMounted(watchConnect)
@@ -106,11 +109,11 @@ const globalShortcutHandler = (event: KeyboardEvent) => {
// Handle arrows: in search input with text, only up/down; otherwise all arrows
const searchInput = inHeader && input
const searchHasText = searchInput && (event.target as HTMLInputElement).value
if (event.key.startsWith("Arrow")) {
if (event.key.startsWith('Arrow')) {
const dir = event.key.slice(5).toLowerCase()
// In search with text: left/right move cursor, up/down navigate
if (searchHasText && (dir === 'left' || dir === 'right')) {
return // Let browser handle cursor movement
return // Let browser handle cursor movement
}
arrow = dir
}
@@ -135,8 +138,7 @@ const globalShortcutHandler = (event: KeyboardEvent) => {
store.clearToast()
headerMain.value!.clearSearch(event)
store.focusBreadcrumb()
}
else if (!input && keyup && event.key === 'Backspace') {
} else if (!input && keyup && event.key === 'Backspace') {
Router.back()
}
// Select all (toggle); keydown to precede and prevent builtin
@@ -151,20 +153,27 @@ const globalShortcutHandler = (event: KeyboardEvent) => {
else if (
!input &&
keyup &&
(event.code === 'Backquote' || event.key === '1' || event.key === '2' || event.key === '3')
(event.code === 'Backquote' ||
event.key === '1' ||
event.key === '2' ||
event.key === '3')
) {
store.sort(['', 'name', 'modified', 'size'][+event.key || 0] as SortOrder)
}
// Rename
else if (!input && c && keyup && !event.ctrlKey && (event.key === 'F2' || event.key === 'r')) {
else if (
!input &&
c &&
keyup &&
!event.ctrlKey &&
(event.key === 'F2' || event.key === 'r')
) {
fileExplorer.cursorRename()
}
// Toggle selections on file explorer; ignore all spaces to prevent scrolling built-in hotkey
else if (!input && c && event.code === 'Space') {
if (keyup && !event.altKey && !event.ctrlKey)
fileExplorer.cursorSelect()
}
else return
if (keyup && !event.altKey && !event.ctrlKey) fileExplorer.cursorSelect()
} else return
/// We are handling this!
event.preventDefault()
if (timer) {
@@ -174,36 +183,67 @@ const globalShortcutHandler = (event: KeyboardEvent) => {
let f: any
// Arrow navigation - always use fileExplorer for repeatable movement
if (arrow && !keyup) {
const focusSearch = () => (document.querySelector('.headermain input[type="search"]') as HTMLElement)?.focus()
const focusBreadcrumb = () => (document.querySelector('.breadcrumb') as HTMLElement)?.focus()
const focusSearch = () =>
(
document.querySelector('.headermain input[type="search"]') as HTMLElement
)?.focus()
const focusBreadcrumb = () =>
(document.querySelector('.breadcrumb') as HTMLElement)?.focus()
if (inBreadcrumb) {
// Breadcrumb: up→header (no repeat), down→files (with repeat)
if (arrow === 'up') { focusSearch(); f = null }
else if (arrow === 'down') { fileExplorer.focusFirst?.(); f = null }
if (arrow === 'up') {
focusSearch()
f = null
} else if (arrow === 'down') {
fileExplorer.focusFirst?.()
f = null
}
} else if (inHeader) {
// Header: left/right navigate focusable items (buttons without tabindex=-1, search input, disk space)
const items = Array.from(document.querySelectorAll('.headermain button:not([tabindex=\"-1\"]), .headermain input[type=\"search\"], .headermain [tabindex=\"0\"]')) as HTMLElement[]
const items = Array.from(
document.querySelectorAll(
'.headermain button:not([tabindex="-1"]), .headermain input[type="search"], .headermain [tabindex="0"]'
)
) as HTMLElement[]
const idx = items.indexOf(document.activeElement as HTMLElement)
if (arrow === 'left' && idx > 0) { items[idx - 1]?.focus(); f = null }
else if (arrow === 'right' && idx < items.length - 1) { items[idx + 1]?.focus(); f = null }
else if (arrow === 'up') f = () => fileExplorer.up({ shiftKey: false })
else if (arrow === 'down') { focusBreadcrumb(); f = null }
if (arrow === 'left' && idx > 0) {
items[idx - 1]?.focus()
f = null
} else if (arrow === 'right' && idx < items.length - 1) {
items[idx + 1]?.focus()
f = null
} else if (arrow === 'up') f = () => fileExplorer.up({ shiftKey: false })
else if (arrow === 'down') {
focusBreadcrumb()
f = null
}
} else {
// File explorer: normal navigation with repeat
switch (arrow) {
case 'up': f = () => fileExplorer.up(event); break
case 'down': f = () => fileExplorer.down(event); break
case 'left': f = () => fileExplorer.left(event); break
case 'right': f = () => fileExplorer.right(event); break
case 'up':
f = () => fileExplorer.up(event)
break
case 'down':
f = () => fileExplorer.down(event)
break
case 'left':
f = () => fileExplorer.left(event)
break
case 'right':
f = () => fileExplorer.right(event)
break
}
}
}
if (f) {
// Initial move, then t0 delay until repeats at tr intervals
const t0 = 200, tr = event.altKey ? 20 : 100
const t0 = 200,
tr = event.altKey ? 20 : 100
f()
timer = setTimeout(() => { timer = setInterval(f, tr) }, t0 - tr)
timer = setTimeout(() => {
timer = setInterval(f, tr)
}, t0 - tr)
}
}
onMounted(() => {
+19 -10
View File
@@ -24,7 +24,7 @@
--header-color: #ccc;
--input-background: var(--soft-color);
--input-color: #ddd;
}
}
}
@media screen and (max-width: 600px) {
.size,
@@ -50,8 +50,12 @@
display: flex;
justify-content: space-between;
}
header .headermain { order: 1; }
header .breadcrumb { align-self: stretch; }
header .headermain {
order: 1;
}
header .breadcrumb {
align-self: stretch;
}
}
@media print {
:root {
@@ -74,7 +78,7 @@
max-width: none !important;
}
.breadcrumb > a::after {
content: '/';
content: "/";
}
.breadcrumb svg {
fill: black !important;
@@ -101,7 +105,8 @@
video::-webkit-media-controls {
display: none;
}
tr, figure {
tr,
figure {
page-break-inside: avoid;
}
.selection {
@@ -134,7 +139,7 @@ main {
body {
background-color: var(--primary-background);
font-size: 1rem;
font-family: 'Roboto';
font-family: "Roboto";
color: var(--primary-color);
margin: 0;
/* Prevent any scrolling on body */
@@ -145,7 +150,7 @@ body {
}
tbody .size,
tbody .modified {
font-family: 'Roboto Mono';
font-family: "Roboto Mono";
}
header {
flex: 0 0 auto;
@@ -209,9 +214,13 @@ header nav.headermain {
position: relative;
z-index: 100;
}
.spacer { flex-grow: 1 }
.smallgap { flex-shrink: 1; width: 2em }
.spacer {
flex-grow: 1;
}
.smallgap {
flex-shrink: 1;
width: 2em;
}
.error-message {
padding: .5em;
+102 -54
View File
@@ -60,78 +60,126 @@ import Zoomout from './zoomout.svg'
// Named exports for direct imports
export {
AddFile, AddFolder, Arrow, ArrowsH, ArrowsV,
Check, Code, Cog, Copy, CreateFile, CreateFolder, Cross,
Disk, Download, Exclamation, Eye, Find, Fullscreen,
Github, Home, Info, Link, Logo, Loop, Menu,
Next, Open, Paste, Pause, Pencil, Play, Plus, Previous,
Reload, Rename, Scissors, Shuffle, Signin, Signout, Skip,
Spinner, Stop, Trash, Triangle, Unfullscreen, UpArrow,
UploadCloud, UserCog, User, VolumeHigh, VolumeLow,
VolumeMedium, VolumeMute, WindowCross, Window, Wordwrap,
Zoomin, Zoomout
AddFile,
AddFolder,
Arrow,
ArrowsH,
ArrowsV,
Check,
Code,
Cog,
Copy,
CreateFile,
CreateFolder,
Cross,
Disk,
Download,
Exclamation,
Eye,
Find,
Fullscreen,
Github,
Home,
Info,
Link,
Logo,
Loop,
Menu,
Next,
Open,
Paste,
Pause,
Pencil,
Play,
Plus,
Previous,
Reload,
Rename,
Scissors,
Shuffle,
Signin,
Signout,
Skip,
Spinner,
Stop,
Trash,
Triangle,
Unfullscreen,
UpArrow,
UploadCloud,
UserCog,
User,
VolumeHigh,
VolumeLow,
VolumeMedium,
VolumeMute,
WindowCross,
Window,
Wordwrap,
Zoomin,
Zoomout
}
// Icon lookup by kebab-case name (for SvgButton compatibility)
export const icons = {
'add-file': AddFile,
'add-folder': AddFolder,
'arrow': Arrow,
arrow: Arrow,
'arrows-h': ArrowsH,
'arrows-v': ArrowsV,
'check': Check,
'code': Code,
'cog': Cog,
'copy': Copy,
check: Check,
code: Code,
cog: Cog,
copy: Copy,
'create-file': CreateFile,
'create-folder': CreateFolder,
'cross': Cross,
'disk': Disk,
'download': Download,
'exclamation': Exclamation,
'eye': Eye,
'find': Find,
'fullscreen': Fullscreen,
'github': Github,
'home': Home,
'info': Info,
'link': Link,
'logo': Logo,
'loop': Loop,
'menu': Menu,
'next': Next,
'open': Open,
'paste': Paste,
'pause': Pause,
'pencil': Pencil,
'play': Play,
'plus': Plus,
'previous': Previous,
'reload': Reload,
'rename': Rename,
'scissors': Scissors,
'shuffle': Shuffle,
'signin': Signin,
'signout': Signout,
'skip': Skip,
'spinner': Spinner,
'stop': Stop,
'trash': Trash,
'triangle': Triangle,
'unfullscreen': Unfullscreen,
cross: Cross,
disk: Disk,
download: Download,
exclamation: Exclamation,
eye: Eye,
find: Find,
fullscreen: Fullscreen,
github: Github,
home: Home,
info: Info,
link: Link,
logo: Logo,
loop: Loop,
menu: Menu,
next: Next,
open: Open,
paste: Paste,
pause: Pause,
pencil: Pencil,
play: Play,
plus: Plus,
previous: Previous,
reload: Reload,
rename: Rename,
scissors: Scissors,
shuffle: Shuffle,
signin: Signin,
signout: Signout,
skip: Skip,
spinner: Spinner,
stop: Stop,
trash: Trash,
triangle: Triangle,
unfullscreen: Unfullscreen,
'up-arrow': UpArrow,
'upload-cloud': UploadCloud,
'user-cog': UserCog,
'user': User,
user: User,
'volume-high': VolumeHigh,
'volume-low': VolumeLow,
'volume-medium': VolumeMedium,
'volume-mute': VolumeMute,
'window-cross': WindowCross,
'window': Window,
'wordwrap': Wordwrap,
'zoomin': Zoomin,
'zoomout': Zoomout,
window: Window,
wordwrap: Wordwrap,
zoomin: Zoomin,
zoomout: Zoomout
} as const
export type IconName = keyof typeof icons
+19 -11
View File
@@ -37,17 +37,21 @@
<script setup lang="ts">
import { Home } from '@/assets/svg'
import { exists } from '@/utils/fileutil'
import { nextTick, onBeforeUpdate, ref, watchEffect } from 'vue'
import { useRouter } from 'vue-router'
import { exists } from '@/utils/fileutil'
import CursorTooltip from './CursorTooltip.vue'
const home = Home
const router = useRouter()
const links = [] as Array<HTMLElement>
const setLinkRef = (index: number, el: any) => { if (el) links[index] = el }
onBeforeUpdate(() => { links.length = 1 }) // 1 to keep home
const setLinkRef = (index: number, el: any) => {
if (el) links[index] = el
}
onBeforeUpdate(() => {
links.length = 1
}) // 1 to keep home
const homeTooltip = ref<InstanceType<typeof CursorTooltip> | null>(null)
const pathTooltips = ref<Map<number, InstanceType<typeof CursorTooltip>>>(new Map())
@@ -63,7 +67,8 @@ const props = defineProps<{
const longest = ref<Array<string>>([])
const isCurrent = (index: number) => index == props.path.length ? 'location' : undefined
const isCurrent = (index: number) =>
index == props.path.length ? 'location' : undefined
const focusCurrent = () => {
nextTick(() => {
@@ -80,7 +85,10 @@ const navigate = (index: number) => {
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); router.push(u) }
if (isCurrent(index)) {
longest.value.splice(index)
router.push(u)
}
// Moving along breadcrumbs doesn't create new history
else if (long.startsWith(browser)) router.replace(u)
// Nornal navigation from elsewhere (e.g. search result breadcrumbs)
@@ -100,8 +108,7 @@ watchEffect(() => {
if (!same) longest.value = props.path
else if (props.path.length > longcut.length) {
longest.value = longcut.concat(props.path.slice(longcut.length))
}
else {
} 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))) {
@@ -111,10 +118,11 @@ watchEffect(() => {
}
}
// If needed, focus primary navigation to new location
if (props.primary) nextTick(() => {
const act = document.activeElement as HTMLElement
if (!act || [...links, document.body].includes(act)) focusCurrent()
})
if (props.primary)
nextTick(() => {
const act = document.activeElement as HTMLElement
if (!act || [...links, document.body].includes(act)) focusCurrent()
})
})
</script>
+34 -21
View File
@@ -62,8 +62,8 @@
</template>
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted } from 'vue'
import { useMainStore } from '@/stores/main'
import { computed, onMounted, onUnmounted, ref } from 'vue'
const store = useMainStore()
const containerRef = ref<HTMLDivElement | null>(null)
@@ -88,7 +88,7 @@ const formatGB = (bytes: number) => {
const fmtSize = (bytes: number, angle: number) => {
const s = formatGB(bytes)
const a = Math.abs(angle % 180)
return (Math.min(a, 180 - a) < 15 && /^[0689]+$/.test(s)) ? `${s}.` : s
return Math.min(a, 180 - a) < 15 && /^[0689]+$/.test(s) ? `${s}.` : s
}
const truncateLabel = (name: string, maxLen = 10): string => {
@@ -157,7 +157,7 @@ const freeColor = computed(() => {
if (!s.disk) return '#6c6'
const freePct = s.free / s.disk
if (freePct > 0.25) return '#5b5'
if (freePct > 0.10) return '#ff0'
if (freePct > 0.1) return '#ff0'
return '#f00'
})
@@ -165,23 +165,24 @@ const PIE_RADIUS = 55
const LABEL_RADIUS = 62
const getPoint = (angle: number, radius: number) => {
const rad = TAU * (angle - 90) / 360
const rad = (TAU * (angle - 90)) / 360
return { x: pieCx + radius * Math.cos(rad), y: pieCy + radius * Math.sin(rad) }
}
const sectorInfo = computed(() => {
const s = store.space
if (!s.disk) return {
storage: { angle: 45, pct: 0.25 },
free: { angle: 180, pct: 0.5 },
other: { angle: 270, pct: 0.25 }
}
if (!s.disk)
return {
storage: { angle: 45, pct: 0.25 },
free: { angle: 180, pct: 0.5 },
other: { angle: 270, pct: 0.25 }
}
const storagePct = s.allocated / s.disk
const freePct = s.free / s.disk
const otherPct = (s.used - s.allocated) / s.disk
const storageAngle = storagePct * 180 // midpoint of storage sector
const storageAngle = storagePct * 180 // midpoint of storage sector
const freeStart = storagePct * 360
const freeAngle = freeStart + freePct * 180
const otherStart = (storagePct + freePct) * 360
@@ -200,13 +201,19 @@ const rawAngles = computed(() => ({
other: sectorInfo.value.other.angle
}))
const getSizeRotation = (angle: number) => angle < 180 ? angle - 90 : angle + 90
const getSizeAnchor = (angle: number) => angle < 180 ? 'end' : 'start'
const getSizeRotation = (angle: number) => (angle < 180 ? angle - 90 : angle + 90)
const getSizeAnchor = (angle: number) => (angle < 180 ? 'end' : 'start')
const INNER_LABEL_RADIUS = PIE_RADIUS * 0.95
const storageInnerPos = computed(() => getPoint(sectorInfo.value.storage.angle, INNER_LABEL_RADIUS))
const freeInnerPos = computed(() => getPoint(sectorInfo.value.free.angle, INNER_LABEL_RADIUS))
const otherInnerPos = computed(() => getPoint(sectorInfo.value.other.angle, INNER_LABEL_RADIUS))
const storageInnerPos = computed(() =>
getPoint(sectorInfo.value.storage.angle, INNER_LABEL_RADIUS)
)
const freeInnerPos = computed(() =>
getPoint(sectorInfo.value.free.angle, INNER_LABEL_RADIUS)
)
const otherInnerPos = computed(() =>
getPoint(sectorInfo.value.other.angle, INNER_LABEL_RADIUS)
)
// Collision avoidance for curved name labels
const labelLengths = computed(() => ({
@@ -269,11 +276,17 @@ const createArcPath = (centerAngle: number, id: string, labelLen: number) => {
}
}
const storageLabelPath = computed(() => createArcPath(adjustedLabelAngles.value.storage!, 'storage', storageName.value.length))
const freeLabelPath = computed(() => createArcPath(adjustedLabelAngles.value.free!, 'free', 4))
const otherLabelPath = computed(() => createArcPath(adjustedLabelAngles.value.other!, 'other', 5))
const storageLabelPath = computed(() =>
createArcPath(adjustedLabelAngles.value.storage!, 'storage', storageName.value.length)
)
const freeLabelPath = computed(() =>
createArcPath(adjustedLabelAngles.value.free!, 'free', 4)
)
const otherLabelPath = computed(() =>
createArcPath(adjustedLabelAngles.value.other!, 'other', 5)
)
const handleClick = () => isExpanded.value ? collapse() : expand()
const handleClick = () => (isExpanded.value ? collapse() : expand())
const applyAnimState = (t: number, opacity: number) => {
const widget = widgetRef.value
@@ -296,9 +309,9 @@ const animate = (duration: number, expanding: boolean, onComplete?: () => void)
const tick = (now: number) => {
const elapsed = now - startTime
const progress = Math.min(elapsed / duration, 1)
const eased = 1 - Math.pow(1 - progress, 3) // easeOutCubic
const eased = 1 - Math.pow(1 - progress, 3) // easeOutCubic
const t = expanding ? eased : 1 - eased
applyAnimState(t, t) // opacity follows position
applyAnimState(t, t) // opacity follows position
if (progress < 1) {
animationFrame = requestAnimationFrame(tick)
} else {
+10 -9
View File
@@ -3,9 +3,9 @@
</template>
<script setup lang="ts">
import { useMainStore } from '@/stores/main'
import { apiFetch } from '@/repositories/Client'
import type { SelectedItems } from '@/repositories/Document'
import { useMainStore } from '@/stores/main'
import { zipName } from '@/utils/fileutil'
const store = useMainStore()
@@ -24,9 +24,9 @@ const status_init = {
filename: '',
filesize: 0,
filepos: 0,
status: 'idle',
status: 'idle'
}
store.dprogress = {...status_init}
store.dprogress = { ...status_init }
setInterval(() => {
if (Date.now() - store.dprogress.tlast > 3000) {
// Reset
@@ -34,8 +34,8 @@ setInterval(() => {
store.dprogress.statdur = 1
} else {
// Running average by decay
store.dprogress.statbytes *= .9
store.dprogress.statdur *= .9
store.dprogress.statbytes *= 0.9
store.dprogress.statdur *= 0.9
}
}, 100)
const statReset = () => {
@@ -44,10 +44,9 @@ const statReset = () => {
store.dprogress.tlast = store.dprogress.t0 + 1
}
const cancelDownloads = () => {
location.reload() // FIXME
location.reload() // FIXME
}
const linkdl = (href: string) => {
const a = document.createElement('a')
a.href = href
@@ -156,7 +155,10 @@ const download = async (e: MouseEvent) => {
if (e.altKey && 'showDirectoryPicker' in window) {
try {
// @ts-ignore
const handle = await window.showDirectoryPicker({ startIn: 'downloads', mode: 'readwrite' })
const handle = await window.showDirectoryPicker({
startIn: 'downloads',
mode: 'readwrite'
})
await filesystemdl(sel, handle)
store.selected.clear()
} catch (e) {
@@ -168,7 +170,6 @@ const download = async (e: MouseEvent) => {
// Default: ZIP download
zipdl(sel)
}
</script>
<style scoped>
+3 -3
View File
@@ -11,15 +11,15 @@
</template>
<script setup lang="ts">
import { useMainStore } from '@/stores/main'
import { Cog } from '@/assets/svg'
import { useMainStore } from '@/stores/main'
import { exists } from '@/utils/fileutil'
const cog = Cog
const store = useMainStore()
const props = defineProps<{
path: string[],
documents: Document[],
path: string[]
documents: Document[]
}>()
</script>
+56 -24
View File
@@ -72,14 +72,22 @@
</template>
<script setup lang="ts">
import { ref, computed, watchEffect, shallowRef, onMounted, onUnmounted, nextTick } from 'vue'
import { useMainStore } from '@/stores/main'
import { Doc } from '@/repositories/Document'
import FileRenameInput from './FileRenameInput.vue'
import { apiFetch } from '@/repositories/Client'
import { Doc } from '@/repositories/Document'
import { useMainStore } from '@/stores/main'
import { formatSize } from '@/utils'
import { useRouter } from 'vue-router'
import ContextMenu from '@imengyu/vue3-context-menu'
import {
computed,
nextTick,
onMounted,
onUnmounted,
ref,
shallowRef,
watchEffect
} from 'vue'
import { useRouter } from 'vue-router'
import FileRenameInput from './FileRenameInput.vue'
const props = defineProps<{
path: Array<string>
@@ -89,7 +97,11 @@ const store = useMainStore()
const router = useRouter()
const filesUrl = (path: string) =>
'/files/' + path.split('/').map(part => encodeURIComponent(part)).join('/')
'/files/' +
path
.split('/')
.map(part => encodeURIComponent(part))
.join('/')
const parseErrorMessage = async (res: Response) => {
try {
@@ -120,7 +132,7 @@ const rename = async (doc: Doc, newName: string) => {
}
defineExpose({
newFolder() {
console.log("New folder")
console.log('New folder')
const now = Math.floor(Date.now() / 1000)
editing.value = new Doc({
loc: loc.value,
@@ -129,7 +141,7 @@ defineExpose({
dir: true,
mtime: now,
size: 0,
allocated: 0,
allocated: 0
})
store.cursor = editing.value.key
},
@@ -146,7 +158,9 @@ defineExpose({
store.cursor = docs[0]!.key
// Also focus the element directly (watchEffect won't trigger if cursor unchanged)
nextTick(() => {
const a = document.querySelector(`#file-${store.cursor} .name a`) as HTMLAnchorElement | null
const a = document.querySelector(
`#file-${store.cursor} .name a`
) as HTMLAnchorElement | null
if (a) a.focus()
})
}
@@ -164,8 +178,12 @@ defineExpose({
}
this.cursorMove(1, null)
},
up(ev: KeyboardEvent) { this.cursorMove(-1, ev) },
down(ev: KeyboardEvent) { this.cursorMove(1, ev) },
up(ev: KeyboardEvent) {
this.cursorMove(-1, ev)
},
down(ev: KeyboardEvent) {
this.cursorMove(1, ev)
},
left(ev: KeyboardEvent) {
// Only go back if we're in a subfolder (not at root)
if (props.path.length > 0) {
@@ -173,7 +191,9 @@ defineExpose({
}
},
right(ev: KeyboardEvent) {
const a = document.querySelector(`#file-${store.cursor} a`) as HTMLAnchorElement | null
const a = document.querySelector(
`#file-${store.cursor} a`
) as HTMLAnchorElement | null
if (a) a.click()
},
cursorMove(d: number, ev: KeyboardEvent | null) {
@@ -187,8 +207,9 @@ defineExpose({
const N = docs.length
const mod = (a: number, b: number) => ((a % b) + b) % b
const increment = (i: number, d: number) => mod(i + d, N + 1)
const index =
store.cursor ? docs.findIndex(doc => doc.key === store.cursor) : docs.length
const index = store.cursor
? docs.findIndex(doc => doc.key === store.cursor)
: docs.length
const moveto = increment(index, d)
store.cursor = docs[moveto]?.key ?? ''
const tr = store.cursor ? document.getElementById(`file-${store.cursor}`) : ''
@@ -206,8 +227,7 @@ defineExpose({
scrolltr = tr
if (!scrolltimer) {
scrolltimer = setTimeout(() => {
if (scrolltr)
scrolltr.scrollIntoView({ block: 'center', behavior: 'smooth' })
if (scrolltr) scrolltr.scrollIntoView({ block: 'center', behavior: 'smooth' })
scrolltimer = null
}, 300)
}
@@ -219,7 +239,9 @@ defineExpose({
}
})
const focusHeader = () => {
const el = document.querySelector('.headermain input[type="search"]') as HTMLElement | null
const el = document.querySelector(
'.headermain input[type="search"]'
) as HTMLElement | null
if (el) el.focus()
}
const focusBreadcrumb = () => {
@@ -250,14 +272,17 @@ const updateModified = () => {
nowkey.value = Math.floor(Date.now() / 1000)
}
onMounted(() => {
updateModified(); modifiedTimer = setInterval(updateModified, 1000)
updateModified()
modifiedTimer = setInterval(updateModified, 1000)
const active = document.querySelector('.cursor') as HTMLElement | null
if (active) {
active.scrollIntoView({ block: 'center', behavior: 'instant' })
active.focus()
}
})
onUnmounted(() => { clearInterval(modifiedTimer) })
onUnmounted(() => {
clearInterval(modifiedTimer)
})
const mkdir = async (doc: Doc, name: string) => {
doc.name = name
doc.key = crypto.randomUUID()
@@ -349,12 +374,14 @@ const copyImage = async (doc: Doc) => {
if (blob.type !== 'image/png') {
const img = new Image()
img.src = URL.createObjectURL(blob)
await new Promise(r => img.onload = r)
await new Promise(r => (img.onload = r))
const canvas = document.createElement('canvas')
canvas.width = img.naturalWidth
canvas.height = img.naturalHeight
canvas.getContext('2d')!.drawImage(img, 0, 0)
const pngBlob = await new Promise<Blob>(r => canvas.toBlob(b => r(b!), 'image/png'))
const pngBlob = await new Promise<Blob>(r =>
canvas.toBlob(b => r(b!), 'image/png')
)
URL.revokeObjectURL(img.src)
await navigator.clipboard.write([new ClipboardItem({ 'image/png': pngBlob })])
} else {
@@ -385,12 +412,17 @@ const contextMenu = (ev: MouseEvent, doc: Doc) => {
store.cursor = doc.key
const items = [
{ label: '📥 Download', onClick: () => downloadFile(doc) },
{ label: '🔗 Copy Link', onClick: () => copyLink(doc) },
{ label: '🔗 Copy Link', onClick: () => copyLink(doc) }
]
if (doc.img) items.push({ label: '📋 Copy Image', onClick: () => copyImage(doc) })
items.push(
{ label: '✏️ Rename', onClick: () => { editing.value = doc } },
{ label: '🗑 Delete', onClick: () => deleteFile(doc) },
{
label: ' Rename',
onClick: () => {
editing.value = doc
}
},
{ label: '🗑️ Delete', onClick: () => deleteFile(doc) }
)
ContextMenu.showContextMenu({ x: ev.x, y: ev.y, items })
}
+3 -3
View File
@@ -17,15 +17,15 @@ import { computed, ref } from 'vue'
import CursorTooltip from './CursorTooltip.vue'
const props = defineProps<{
doc: Doc
now: number
doc: Doc
now: number
}>()
const tooltip = ref<InstanceType<typeof CursorTooltip> | null>(null)
// Reference props.now to trigger reactivity when time updates
const modified = computed(() => {
props.now // trigger reactivity
props.now // trigger reactivity
return formatUnixDate(props.doc.mtime)
})
+1 -1
View File
@@ -13,7 +13,7 @@
<script setup lang="ts">
import { Doc } from '@/repositories/Document'
import { ref, onMounted, nextTick } from 'vue'
import { nextTick, onMounted, ref } from 'vue'
const input = ref<HTMLInputElement | null>(null)
const name = ref('')
+4 -4
View File
@@ -13,20 +13,20 @@
<script setup lang="ts">
import { Doc } from '@/repositories/Document'
import { computed, ref } from 'vue'
import { formatSize } from '@/utils'
import SparseIndicator from './SparseIndicator.vue'
import { computed, ref } from 'vue'
import CursorTooltip from './CursorTooltip.vue'
import SparseIndicator from './SparseIndicator.vue'
const props = defineProps<{
doc: Doc
doc: Doc
}>()
const tooltip = ref<InstanceType<typeof CursorTooltip> | null>(null)
const sizeClass = computed(() => {
const unit = props.doc.sizedisp.split('\u202F').slice(-1)[0]!
return +unit ? "bytes" : unit
return +unit ? 'bytes' : unit
})
const tooltipText = computed(() => {
+66 -28
View File
@@ -9,13 +9,21 @@
</template>
<script setup lang="ts">
import { ref, computed, watchEffect, shallowRef, onMounted, onUnmounted, nextTick } from 'vue'
import { useMainStore } from '@/stores/main'
import { Doc } from '@/repositories/Document'
import { apiFetch } from '@/repositories/Client'
import { useRouter } from 'vue-router'
import ContextMenu from '@imengyu/vue3-context-menu'
import { Doc } from '@/repositories/Document'
import { useMainStore } from '@/stores/main'
import type { SortOrder } from '@/utils/docsort'
import ContextMenu from '@imengyu/vue3-context-menu'
import {
computed,
nextTick,
onMounted,
onUnmounted,
ref,
shallowRef,
watchEffect
} from 'vue'
import { useRouter } from 'vue-router'
const props = defineProps<{
path: Array<string>
@@ -25,7 +33,11 @@ const store = useMainStore()
const router = useRouter()
const filesUrl = (path: string) =>
'/files/' + path.split('/').map(part => encodeURIComponent(part)).join('/')
'/files/' +
path
.split('/')
.map(part => encodeURIComponent(part))
.join('/')
const parseErrorMessage = async (res: Response) => {
try {
@@ -38,7 +50,9 @@ const parseErrorMessage = async (res: Response) => {
// File rename
const editing = shallowRef<Doc | null>(null)
const exit = () => { editing.value = null }
const exit = () => {
editing.value = null
}
const rename = async (doc: Doc, newName: string) => {
const oldName = doc.name
doc.name = newName // We should get an update from watch but this is quicker
@@ -59,7 +73,9 @@ const gallery = ref<HTMLElement>()
const columnCount = ref(1)
const updateColumns = () => {
if (!gallery.value) return
columnCount.value = getComputedStyle(gallery.value).gridTemplateColumns.split(' ').length
columnCount.value = getComputedStyle(gallery.value).gridTemplateColumns.split(
' '
).length
}
const columns = computed(() => columnCount.value)
defineExpose({
@@ -72,7 +88,7 @@ defineExpose({
dir: true,
mtime: now,
size: 0,
allocated: 0,
allocated: 0
})
store.cursor = editing.value.key
},
@@ -93,7 +109,9 @@ defineExpose({
store.cursor = docs[0]!.key
// Also focus the element directly (watchEffect won't trigger if cursor unchanged)
nextTick(() => {
const a = document.querySelector(`#file-${store.cursor}`) as HTMLAnchorElement | null
const a = document.querySelector(
`#file-${store.cursor}`
) as HTMLAnchorElement | null
if (a) a.focus()
})
}
@@ -111,10 +129,18 @@ defineExpose({
}
this.cursorMove(1, null)
},
up(ev: KeyboardEvent) { this.cursorMove(-columns.value, ev) },
down(ev: KeyboardEvent) { this.cursorMove(columns.value, ev) },
left(ev: KeyboardEvent) { this.cursorMove(-1, ev) },
right(ev: KeyboardEvent) { this.cursorMove(1, ev) },
up(ev: KeyboardEvent) {
this.cursorMove(-columns.value, ev)
},
down(ev: KeyboardEvent) {
this.cursorMove(columns.value, ev)
},
left(ev: KeyboardEvent) {
this.cursorMove(-1, ev)
},
right(ev: KeyboardEvent) {
this.cursorMove(1, ev)
},
cursorMove(d: number, ev: KeyboardEvent | null) {
const select = !!ev?.shiftKey
// Move cursor up or down (keyboard navigation)
@@ -126,11 +152,10 @@ defineExpose({
const N = docs.length
const mod = (a: number, b: number) => ((a % b) + b) % b
const increment = (i: number, d: number) => mod(i + d, N + 1)
const index =
store.cursor ? docs.findIndex(doc => doc.key === store.cursor) : N
const index = store.cursor ? docs.findIndex(doc => doc.key === store.cursor) : N
// Stop navigation sideways away from the grid (only with up/down)
if (ev && index === 0 && ev.key === "ArrowLeft") return
if (ev && index === N - 1 && ev.key === "ArrowRight") return
if (ev && index === 0 && ev.key === 'ArrowLeft') return
if (ev && index === N - 1 && ev.key === 'ArrowRight') return
// Calculate new position
let moveto
if (index === N) moveto = d > 0 ? 0 : N - 1
@@ -155,8 +180,7 @@ defineExpose({
scrolltr = tr
if (!scrolltimer) {
scrolltimer = setTimeout(() => {
if (scrolltr)
scrolltr.scrollIntoView({ block: 'center', behavior: 'smooth' })
if (scrolltr) scrolltr.scrollIntoView({ block: 'center', behavior: 'smooth' })
scrolltimer = null
}, 300)
}
@@ -168,7 +192,9 @@ defineExpose({
}
})
const focusHeader = () => {
const el = document.querySelector('.headermain input[type="search"]') as HTMLElement | null
const el = document.querySelector(
'.headermain input[type="search"]'
) as HTMLElement | null
if (el) el.focus()
}
const focusBreadcrumb = () => {
@@ -181,8 +207,13 @@ watchEffect(() => {
if (store.cursor && store.cursor !== editing.value?.key) editing.value = null
if (editing.value) store.cursor = editing.value.key
if (store.cursor) {
const a = document.querySelector(`#file-${store.cursor}`) as HTMLAnchorElement | null
if (a) { a.focus(); a.scrollIntoView({ block: 'center', behavior: 'smooth' }) }
const a = document.querySelector(
`#file-${store.cursor}`
) as HTMLAnchorElement | null
if (a) {
a.focus()
a.scrollIntoView({ block: 'center', behavior: 'smooth' })
}
}
})
watchEffect(() => {
@@ -288,12 +319,14 @@ const copyImage = async (doc: Doc) => {
if (blob.type !== 'image/png') {
const img = new Image()
img.src = URL.createObjectURL(blob)
await new Promise(r => img.onload = r)
await new Promise(r => (img.onload = r))
const canvas = document.createElement('canvas')
canvas.width = img.naturalWidth
canvas.height = img.naturalHeight
canvas.getContext('2d')!.drawImage(img, 0, 0)
const pngBlob = await new Promise<Blob>(r => canvas.toBlob(b => r(b!), 'image/png'))
const pngBlob = await new Promise<Blob>(r =>
canvas.toBlob(b => r(b!), 'image/png')
)
URL.revokeObjectURL(img.src)
await navigator.clipboard.write([new ClipboardItem({ 'image/png': pngBlob })])
} else {
@@ -324,12 +357,17 @@ const contextMenu = (ev: MouseEvent, doc: Doc) => {
store.cursor = doc.key
const items = [
{ label: '📥 Download', onClick: () => downloadFile(doc) },
{ label: '🔗 Copy Link', onClick: () => copyLink(doc) },
{ label: '🔗 Copy Link', onClick: () => copyLink(doc) }
]
if (doc.img) items.push({ label: '📋 Copy Image', onClick: () => copyImage(doc) })
items.push(
{ label: '✏️ Rename', onClick: () => { editing.value = doc } },
{ label: '🗑 Delete', onClick: () => deleteFile(doc) },
{
label: ' Rename',
onClick: () => {
editing.value = doc
}
},
{ label: '🗑️ Delete', onClick: () => deleteFile(doc) }
)
ContextMenu.showContextMenu({ x: ev.x, y: ev.y, items })
}
+9 -9
View File
@@ -31,24 +31,24 @@
</a>
</template>
<script setup lang=ts>
import { ref, computed } from 'vue'
import { useMainStore } from '@/stores/main'
import { Doc } from '@/repositories/Document'
import { formatSize } from '@/utils'
<script setup lang="ts">
import MediaPreview from '@/components/MediaPreview.vue'
import { Doc } from '@/repositories/Document'
import { useMainStore } from '@/stores/main'
import { formatSize } from '@/utils'
import { computed, ref } from 'vue'
import CursorTooltip from './CursorTooltip.vue'
import SparseIndicator from './SparseIndicator.vue'
const store = useMainStore()
type EditingProp = {
rename: (name: string) => void;
exit: () => void;
rename: (name: string) => void
exit: () => void
}
const props = defineProps<{
doc: Doc,
editing?: EditingProp,
doc: Doc
editing?: EditingProp
}>()
const m = ref<typeof MediaPreview | null>(null)
const tooltip = ref<InstanceType<typeof CursorTooltip> | null>(null)
+39 -18
View File
@@ -26,13 +26,13 @@
</template>
<script setup lang="ts">
import { resumeWatching } from '@/repositories/WS'
import router from '@/router'
import { useMainStore } from '@/stores/main'
import { useSsoAuthStore } from '@/stores/ssoAuth'
import { ref } from 'vue'
import ContextMenu from '@imengyu/vue3-context-menu'
import { showAuthIframe } from 'paskia'
import { resumeWatching } from '@/repositories/WS'
import router from '@/router';
import { ref } from 'vue'
import DiskSpace from './DiskSpace.vue'
const store = useMainStore()
@@ -78,7 +78,7 @@ const updateSearch = (ev: Event) => {
pendingRouteUpdate = null
let p = loc
p = p ? `/${p}` : ''
const url = q ? `${p}//${q}` : (p || '/')
const url = q ? `${p}//${q}` : p || '/'
const u = url.replaceAll('?', '%3F').replaceAll('#', '%23')
// Use replace to avoid building up history for each keystroke
router.replace(u)
@@ -96,45 +96,66 @@ const settingsMenu = (e: Event) => {
if (ssoStore.isExternalAuth && store.user.isLoggedIn) {
items.push({
label: '👤 ' + (store.user.username || 'User Account'),
onClick: () => { window.location.href = '/auth/' }
onClick: () => {
window.location.href = '/auth/'
}
})
}
// Only show password change for non-SSO users
if (!ssoStore.isExternalAuth && store.user.isLoggedIn) {
items.push({ label: '🔑 Change Password', onClick: () => { store.dialog = 'settings' }})
items.push({
label: '🔑 Change Password',
onClick: () => {
store.dialog = 'settings'
}
})
}
if (store.user.isLoggedIn) {
items.push({ label: '🔑 API Tokens', onClick: () => { store.dialog = 'tokens' }})
items.push({
label: '🔑 API Tokens',
onClick: () => {
store.dialog = 'tokens'
}
})
}
if (store.user.privileged) {
items.push({ label: '⚙️ Admin Settings', onClick: () => { store.dialog = 'usermgmt' }})
items.push({
label: '⚙️ Admin Settings',
onClick: () => {
store.dialog = 'usermgmt'
}
})
}
if (store.user.isLoggedIn) {
items.push({ label: '🚪 Logout', onClick: () => store.logout() })
} else if (store.server.public) {
// Show login option only in public mode (non-public modes trigger auth automatically)
items.push({ label: '🔐 Login', onClick: async () => {
try {
await showAuthIframe('/auth/restricted/#theme=light')
resumeWatching()
} catch (e) {
console.log('Login cancelled')
items.push({
label: '🔐 Login',
onClick: async () => {
try {
await showAuthIframe('/auth/restricted/#theme=light')
resumeWatching()
} catch (e) {
console.log('Login cancelled')
}
}
}})
})
}
ContextMenu.showContextMenu({
// @ts-ignore
x: e.target.getBoundingClientRect().right, y: e.target.getBoundingClientRect().bottom,
items,
x: e.target.getBoundingClientRect().right,
y: e.target.getBoundingClientRect().bottom,
items
})
}
defineExpose({
toggleSearchInput,
clearSearch,
clearSearch
})
</script>
+4 -4
View File
@@ -13,9 +13,9 @@
</template>
<script setup lang="ts">
import { ref, watchEffect, nextTick } from 'vue'
import { useMainStore } from '@/stores/main'
import { holdGlobalBackdrop, releaseGlobalBackdrop } from 'paskia'
import { nextTick, ref, watchEffect } from 'vue'
const overlay = ref<HTMLDivElement | null>(null)
const dialog = ref<HTMLDivElement | null>(null)
@@ -27,9 +27,9 @@ const close = () => {
}
const props = defineProps<{
title: string,
name: typeof store.dialog,
}>()
title: string
name: typeof store.dialog
}>()
const show = () => {
store.dialog = props.name
+2 -3
View File
@@ -10,13 +10,12 @@
>
</template>
<script setup lang=ts>
import { useMainStore } from '@/stores/main'
<script setup lang="ts">
import type { Doc } from '@/repositories/Document'
import { useMainStore } from '@/stores/main'
const props = defineProps<{
doc: Doc
}>()
const store = useMainStore()
</script>
+10 -7
View File
@@ -30,11 +30,11 @@
<script setup lang="ts">
import { apiFetch } from '@/repositories/Client'
import { useMainStore } from '@/stores/main'
import { computed, ref } from 'vue'
import { formatSize } from '@/utils'
import CursorTooltip from './CursorTooltip.vue'
import router from '@/router'
import { useMainStore } from '@/stores/main'
import { formatSize } from '@/utils'
import { computed, ref } from 'vue'
import CursorTooltip from './CursorTooltip.vue'
const unselectTooltip = ref<InstanceType<typeof CursorTooltip> | null>(null)
@@ -50,7 +50,11 @@ const navigateTo = (path: string) => {
}
const filesUrl = (path: string) =>
'/files/' + path.split('/').map(part => encodeURIComponent(part)).join('/')
'/files/' +
path
.split('/')
.map(part => encodeURIComponent(part))
.join('/')
const parseErrorMessage = async (res: Response) => {
try {
@@ -110,7 +114,7 @@ const selectionDisplay = computed<SelectionDisplay>(() => {
if (count === 1) {
displayName = truncateName(names[0]!)
} else {
const folderName = loc ? loc.split('/').pop()! : (store.server.name || 'Root')
const folderName = loc ? loc.split('/').pop()! : store.server.name || 'Root'
displayName = `${truncateName(folderName)} (${count})`
}
return {
@@ -166,7 +170,6 @@ const op = async (opName: string, dst?: string) => {
}
}
}
</script>
<style>
+2 -2
View File
@@ -44,10 +44,10 @@
</template>
<script lang="ts" setup>
import { reactive, ref } from 'vue'
import { changePassword } from '@/repositories/User'
import type { ISimpleError } from '@/repositories/Client'
import { changePassword } from '@/repositories/User'
import { useMainStore } from '@/stores/main'
import { reactive, ref } from 'vue'
const confirmLoading = ref<boolean>(false)
const store = useMainStore()
+1 -1
View File
@@ -13,7 +13,7 @@
</template>
<script setup lang="ts">
import { icons, type IconName } from '@/assets/svg'
import { type IconName, icons } from '@/assets/svg'
import { ref } from 'vue'
import CursorTooltip from './CursorTooltip.vue'
+8 -7
View File
@@ -19,7 +19,7 @@
</template>
<script setup lang="ts">
import { computed } from 'vue';
import { computed } from 'vue'
defineEmits(['cancel'])
@@ -38,16 +38,17 @@ const props = defineProps<{
}
}>()
const percent = computed(() => props.status.xfer / props.status.total * 100)
const percent = computed(() => (props.status.xfer / props.status.total) * 100)
const speed = computed(() => {
let s = props.status.statbytes / props.status.statdur / 1e3
const tsince = (Date.now() - props.status.tlast) / 1e3
if (tsince > 5 / s) return 0 // Less than fifth of previous speed => stalled
if (tsince > 1 / s) return 1 / tsince // Next block is late or not coming, decay
return s // "Current speed"
if (tsince > 5 / s) return 0 // Less than fifth of previous speed => stalled
if (tsince > 1 / s) return 1 / tsince // Next block is late or not coming, decay
return s // "Current speed"
})
const speeddisp = computed(() => speed.value ? speed.value.toFixed(speed.value < 10 ? 1 : 0) + '\u202FMB/s': 'stalled')
const speeddisp = computed(() =>
speed.value ? speed.value.toFixed(speed.value < 10 ? 1 : 0) + '\u202FMB/s' : 'stalled'
)
</script>
<style scoped>
+68 -38
View File
@@ -8,10 +8,10 @@
</template>
<script setup lang="ts">
import { useMainStore } from '@/stores/main'
import { getDocuments } from '@/stores/documentStore'
import { Doc } from '@/repositories/Document'
import { collator } from '@/utils';
import { getDocuments } from '@/stores/documentStore'
import { useMainStore } from '@/stores/main'
import { collator } from '@/utils'
import { onMounted, onUnmounted, ref } from 'vue'
import { useRouter } from 'vue-router'
@@ -43,7 +43,7 @@ type InflightBlock = {
startedAt: number
}
const UPLOAD_BLOCK_SIZE = 16 << 20 // 16 MiB
const UPLOAD_BLOCK_SIZE = 16 << 20 // 16 MiB
function pasteHandler(event: ClipboardEvent) {
const items = Array.from(event.clipboardData?.items ?? [])
const infiles = [] as File[]
@@ -62,7 +62,8 @@ function pasteHandler(event: ClipboardEvent) {
event.preventDefault()
uploadFiles(infiles)
const base = props.path!.join('/')
for (const entry of dirs) pasteDirectory(entry, `${base ? `${base}/` : ''}${entry.name}`)
for (const entry of dirs)
pasteDirectory(entry, `${base ? `${base}/` : ''}${entry.name}`)
}
}
const pasteDirectory = async (entry: FileSystemDirectoryEntry, loc: string) => {
@@ -72,8 +73,8 @@ const pasteDirectory = async (entry: FileSystemDirectoryEntry, loc: string) => {
for (const entry of entries) {
const cloudName = `${loc}/${entry.name}`
if (entry.isFile) {
const file = await new Promise(resolve => entry.file(resolve)) as File
cloudfiles.push({file, cloudName, cloudPos: 0})
const file = (await new Promise(resolve => entry.file(resolve))) as File
cloudfiles.push({ file, cloudName, cloudPos: 0 })
} else if (entry.isDirectory) {
await pasteDirectory(entry, cloudName)
}
@@ -84,7 +85,9 @@ function uploadHandler(event: Event) {
event.preventDefault()
// @ts-ignore
const input = event.target as HTMLInputElement | null
const infiles = Array.from((input ?? (event as DragEvent).dataTransfer)?.files ?? []) as File[]
const infiles = Array.from(
(input ?? (event as DragEvent).dataTransfer)?.files ?? []
) as File[]
if (input) input.value = ''
if (infiles.length) uploadFiles(infiles)
}
@@ -99,7 +102,7 @@ const uploadFiles = (infiles: File[]) => {
files.push({
file,
cloudName: `${loc ? `${loc}/` : ''}${relPath}`,
cloudPos: 0,
cloudPos: 0
})
}
uploadCloudFiles(files)
@@ -131,13 +134,34 @@ const uploadCloudFiles = (files: CloudFile[]) => {
for (let i = 0; i < parts.length; i++) {
const folderPath = parts.slice(0, i + 1).join('/')
if (folderPath && !byPath.has(folderPath) && !added.has(folderPath)) {
store.addGhost(new Doc({ loc: parts.slice(0, i).join('/'), name: parts[i], key: crypto.randomUUID(), size: 0, allocated: 0, mtime: now, dir: true }))
store.addGhost(
new Doc({
loc: parts.slice(0, i).join('/'),
name: parts[i],
key: crypto.randomUUID(),
size: 0,
allocated: 0,
mtime: now,
dir: true
})
)
added.add(folderPath)
}
}
// Ghost file or update existing (overwrite case doesn't need ghost, file already visible)
const existing = byPath.get(f.cloudName)
if (!existing) store.addGhost(new Doc({ loc, name, key: crypto.randomUUID(), size: f.file.size, allocated: 0, mtime: now, dir: false }))
if (!existing)
store.addGhost(
new Doc({
loc,
name,
key: crypto.randomUUID(),
size: f.file.size,
allocated: 0,
mtime: now,
dir: false
})
)
}
// @ts-ignore
upqueue = [...upqueue, ...files]
@@ -169,9 +193,9 @@ const uprogress_init = {
filename: '',
filesize: 0,
filepos: 0,
status: 'idle',
status: 'idle'
}
store.uprogress = {...uprogress_init}
store.uprogress = { ...uprogress_init }
// Track uploaded bytes for each file to handle out-of-order uploads
const uploadedBytes = new Map<string, Set<number>>()
const inflightBlocks = new Map<string, InflightBlock>()
@@ -240,13 +264,13 @@ setInterval(() => {
store.uprogress.statbytes = 0
store.uprogress.statdur = 1
} else {
store.uprogress.statbytes *= .95
store.uprogress.statdur *= .95
store.uprogress.statbytes *= 0.95
store.uprogress.statdur *= 0.95
}
}, 100)
const statUpdate = ({name, size, start, end}: UploadRange) => {
if (name !== store.uprogress.filename) return // If stats have been reset
const statUpdate = ({ name, size, start, end }: UploadRange) => {
if (name !== store.uprogress.filename) return // If stats have been reset
// Track which bytes have been uploaded (using start to end range)
if (!uploadedBytes.has(name)) uploadedBytes.set(name, new Set())
@@ -263,9 +287,12 @@ const statUpdate = ({name, size, start, end}: UploadRange) => {
const currentUpload = blockQueue[0]
if (!currentUpload) return
if (currentUpload.file.cloudName === name && currentUpload.completed >= currentUpload.blocks.length) {
if (
currentUpload.file.cloudName === name &&
currentUpload.completed >= currentUpload.blocks.length
) {
// All blocks for this file have been uploaded
uploadedBytes.delete(name) // Clean up tracking
uploadedBytes.delete(name) // Clean up tracking
store.uprogress.filestart += size
statNextFile()
if (++store.uprogress.fileidx >= store.uprogress.filecount) statReset()
@@ -299,35 +326,35 @@ const MAX_PARALLEL_REQUESTS = 4
const RETRY_DELAY_MS = 400
// Helper function to get upload blocks for a file, prioritizing final 4 blocks if file >= 32 MiB
const getUploadBlocks = (file: CloudFile): {start: number, end: number}[] => {
const getUploadBlocks = (file: CloudFile): { start: number; end: number }[] => {
const BLOCK_SIZE = UPLOAD_BLOCK_SIZE
const MIN_SIZE_FOR_REORDER = 32 * BLOCK_SIZE // 32 MiB = 33554432 bytes
const MIN_SIZE_FOR_REORDER = 32 * BLOCK_SIZE // 32 MiB = 33554432 bytes
const FINAL_BLOCKS_COUNT = 2
const fileSize = file.file.size
const blocks: {start: number, end: number}[] = []
const blocks: { start: number; end: number }[] = []
if (fileSize >= MIN_SIZE_FOR_REORDER) {
// File is large enough, prioritize final blocks
const finalBlocksStart = fileSize - (FINAL_BLOCKS_COUNT * BLOCK_SIZE)
const finalBlocksStart = fileSize - FINAL_BLOCKS_COUNT * BLOCK_SIZE
// Add final blocks first
for (let i = 0; i < FINAL_BLOCKS_COUNT; i++) {
const start = finalBlocksStart + (i * BLOCK_SIZE)
const start = finalBlocksStart + i * BLOCK_SIZE
const end = Math.min(start + BLOCK_SIZE, fileSize)
blocks.push({start, end})
blocks.push({ start, end })
}
// Add remaining blocks from beginning
for (let start = 0; start < finalBlocksStart; start += BLOCK_SIZE) {
const end = Math.min(start + BLOCK_SIZE, finalBlocksStart)
blocks.push({start, end})
blocks.push({ start, end })
}
} else {
// File is smaller, use sequential upload
for (let start = 0; start < fileSize; start += BLOCK_SIZE) {
const end = Math.min(start + BLOCK_SIZE, fileSize)
blocks.push({start, end})
blocks.push({ start, end })
}
}
@@ -336,7 +363,7 @@ const getUploadBlocks = (file: CloudFile): {start: number, end: number}[] => {
type BlockUpload = {
file: CloudFile
blocks: {start: number, end: number}[]
blocks: { start: number; end: number }[]
nextIndex: number
completed: number
runId: number
@@ -360,14 +387,17 @@ const uploadUrlForFile = (cloudName: string) => {
return `/files/${encoded}`
}
const uploadBlock = async (upload: BlockUpload, block: {start: number, end: number}) => {
const uploadBlock = async (
upload: BlockUpload,
block: { start: number; end: number }
) => {
const body = upload.file.file.slice(block.start, block.end)
const range = `bytes ${block.start}-${block.end - 1}/${upload.file.file.size}`
const fallbackReq = {
name: upload.file.cloudName,
size: upload.file.file.size,
start: block.start,
end: block.end,
end: block.end
}
let attempt = 0
@@ -379,9 +409,9 @@ const uploadBlock = async (upload: BlockUpload, block: {start: number, end: numb
method: 'PUT',
headers: {
'Content-Type': 'application/octet-stream',
'Content-Range': range,
'Content-Range': range
},
body,
body
})
if (!res.ok) {
const message = await res.text().catch(() => '')
@@ -404,16 +434,16 @@ const uploadBlock = async (upload: BlockUpload, block: {start: number, end: numb
}
}
const startInflightBlock = (name: string, block: {start: number, end: number}) => {
const startInflightBlock = (name: string, block: { start: number; end: number }) => {
inflightBlocks.set(inflightKey(name, block.start), {
name,
start: block.start,
end: block.end,
startedAt: Date.now(),
startedAt: Date.now()
})
}
const finishInflightBlock = (name: string, block: {start: number, end: number}) => {
const finishInflightBlock = (name: string, block: { start: number; end: number }) => {
const key = inflightKey(name, block.start)
const info = inflightBlocks.get(key)
if (!info) return
@@ -433,9 +463,9 @@ const worker = async (runId: number) => {
while (runId === uploadRunId && upload.completed < upload.blocks.length) {
while (
runId === uploadRunId
&& upload.nextIndex < upload.blocks.length
&& inflight.size < MAX_PARALLEL_REQUESTS
runId === uploadRunId &&
upload.nextIndex < upload.blocks.length &&
inflight.size < MAX_PARALLEL_REQUESTS
) {
const block = upload.blocks[upload.nextIndex++]!
store.uprogress.status = 'uploading'
+35 -15
View File
@@ -74,10 +74,18 @@
</template>
<script lang="ts" setup>
import { ref, reactive, onMounted, watch } from 'vue'
import { listUsers, createUser, updateUser, deleteUser, updatePublic, updateServerName, getServerConfig } from '@/repositories/User'
import type { ISimpleError } from '@/repositories/Client'
import {
createUser,
deleteUser,
getServerConfig,
listUsers,
updatePublic,
updateServerName,
updateUser
} from '@/repositories/User'
import { useMainStore } from '@/stores/main'
import { onMounted, reactive, ref, watch } from 'vue'
interface User {
username: string
@@ -92,7 +100,7 @@ const success = ref('')
const copyButtonText = ref('📋')
const serverSettings = reactive({
public: false,
name: '',
name: ''
})
let nameDebounceTimer: ReturnType<typeof setTimeout> | null = null
@@ -163,10 +171,13 @@ const renameUser = async (user: User) => {
}
const resetPassword = async (user: User) => {
if (!confirm(`Reset password for ${user.username}? A new password will be generated.`)) return
if (
!confirm(`Reset password for ${user.username}? A new password will be generated.`)
)
return
try {
success.value = ''
const result = await updateUser(user.username, { password: "" })
const result = await updateUser(user.username, { password: '' })
if (result.password) {
success.value = `Password reset for ${user.username}. New password: ${result.password}`
}
@@ -195,7 +206,10 @@ const copySuccess = async (isButtonClick: boolean = false) => {
// Show "Copied!" indication on button
copyButtonText.value = '✅ Copied!'
// Hide password/key and button immediately after copying
const baseMessage = success.value.replace(/(?:Password|New password|Key): .+/, 'Copied to clipboard!')
const baseMessage = success.value.replace(
/(?:Password|New password|Key): .+/,
'Copied to clipboard!'
)
success.value = baseMessage
// Hide the entire message after 3 seconds
setTimeout(() => {
@@ -258,18 +272,24 @@ onMounted(() => {
})
// Load users and config when dialog opens
watch(() => store.dialog, (newVal) => {
if (newVal === 'usermgmt') {
loadServerConfig()
if (!store.server.paskia) {
loadUsers()
watch(
() => store.dialog,
newVal => {
if (newVal === 'usermgmt') {
loadServerConfig()
if (!store.server.paskia) {
loadUsers()
}
}
}
})
)
watch(() => store.server.public, (newVal) => {
serverSettings.public = newVal || false
})
watch(
() => store.server.public,
newVal => {
serverSettings.public = newVal || false
}
)
</script>
<style scoped>
+11 -8
View File
@@ -67,10 +67,10 @@
</template>
<script lang="ts" setup>
import { ref, watch, nextTick } from 'vue'
import { listTokens, createToken, deleteToken } from '@/repositories/User'
import type { ISimpleError } from '@/repositories/Client'
import { createToken, deleteToken, listTokens } from '@/repositories/User'
import { useMainStore } from '@/stores/main'
import { nextTick, ref, watch } from 'vue'
interface Token {
id: string
@@ -145,7 +145,7 @@ const submitCreate = async () => {
if (result.url) {
createdToken.value = {
...(result as CreatedToken),
url: ensureFilesBaseUrl((result as CreatedToken).url),
url: ensureFilesBaseUrl((result as CreatedToken).url)
}
mode.value = 'created'
}
@@ -188,12 +188,15 @@ const formatDate = (ts: number) => {
}
// Load tokens when dialog opens
watch(() => store.dialog, (newVal) => {
if (newVal === 'tokens') {
resetCreate()
loadTokens()
watch(
() => store.dialog,
newVal => {
if (newVal === 'tokens') {
resetCreate()
loadTokens()
}
}
})
)
</script>
<style scoped>
+1 -1
View File
@@ -1,7 +1,7 @@
import './assets/main.css'
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import { createApp } from 'vue'
import App from './App.vue'
import router from './router'
+1 -1
View File
@@ -1,4 +1,4 @@
import { apiJson, apiFetch, AuthCancelledError } from 'paskia'
import { AuthCancelledError, apiFetch, apiJson } from 'paskia'
// Type for API error responses
interface ApiError {
+39 -21
View File
@@ -1,4 +1,4 @@
import { formatSize, formatUnixDate } from "@/utils"
import { formatSize, formatUnixDate } from '@/utils'
export type FUID = string
@@ -11,45 +11,53 @@ export type DocProps = {
mtime: number
dir: boolean
ghost?: boolean
expires?: number // Unix timestamp for ghost expiry
expires?: number // Unix timestamp for ghost expiry
}
export class Doc {
public loc: string = ""
public key: FUID = ""
public loc: string = ''
public key: FUID = ''
public size: number = 0
public allocated: number = 0
public mtime: number = 0
public dir: boolean = false
public ghost: boolean = false
public expires: number = 0 // Unix timestamp for ghost expiry (0 = no expiry)
public expires: number = 0 // Unix timestamp for ghost expiry (0 = no expiry)
/** @internal Use the name getter/setter instead */
public _name: string = ""
public _name: string = ''
constructor(props: Partial<DocProps> = {}) {
const { name, ...rest } = props
Object.assign(this, rest)
if (name) this._name = name // Skip validation/haystack for bulk loading
if (name) this._name = name // Skip validation/haystack for bulk loading
}
get name() {
return this._name
}
get name() { return this._name }
set name(name: string) {
if (name.includes('/') || name.startsWith('.')) throw Error(`Invalid name: ${name}`)
this._name = name
}
get sizedisp(): string { return formatSize(this.size) }
get sizedisp(): string {
return formatSize(this.size)
}
/** Returns a sparse allocation indicator symbol, or empty string if fully allocated */
get sparseIndicator(): string {
if (this.dir || this.size <= this.allocated) return ''
if (this.allocated === 0) return '⭕' // exactly zero
if (this.allocated === 0) return '⭕' // exactly zero
const ratio = this.allocated / this.size
// Round to nearest 25%: ◔◑◕⬤
const rounded = Math.round(ratio * 4) // 0,1,2,3,4
return ['◔', '◔', '◑', '◕', '⬤'][rounded]! // 0 maps to ◔ since we handled exact 0 above
const rounded = Math.round(ratio * 4) // 0,1,2,3,4
return ['◔', '◔', '◑', '◕', '⬤'][rounded]! // 0 maps to ◔ since we handled exact 0 above
}
get modified(): string {
return formatUnixDate(this.mtime)
}
get modified(): string { return formatUnixDate(this.mtime) }
get url(): string {
const p = this.loc ? `${this.loc}/${this.name}` : this.name
return this.dir ? '/#/' + `${p}/`.replaceAll('#', '%23') : `/files/${p}`.replaceAll('?', '%3F').replaceAll('#', '%23')
return this.dir
? '/#/' + `${p}/`.replaceAll('#', '%23')
: `/files/${p}`.replaceAll('?', '%3F').replaceAll('#', '%23')
}
get urlrouter(): string {
return this.url.replace(/^\/#/, '')
@@ -57,7 +65,17 @@ export class Doc {
get img(): boolean {
// Folders cannot be images
if (this.dir) return false
return ['jpg', 'jpeg', 'png', 'gif', 'webp', 'avif', 'heic', 'heif', 'svg'].includes(this.ext)
return [
'jpg',
'jpeg',
'png',
'gif',
'webp',
'avif',
'heic',
'heif',
'svg'
].includes(this.ext)
}
get complete(): boolean {
return !this.ghost && (this.dir || this.size <= this.allocated)
@@ -90,13 +108,13 @@ export type errorEvent = {
// Raw types the backend /api/watch sends us
export type FileEntry = [
number, // level
string, // name
number, // level
string, // name
FUID,
number, // mtime
number, // size
number, // allocated (actual disk usage)
number, // isfile
number, // mtime
number, // size
number, // allocated (actual disk usage)
number // isfile
]
export type UpdateEntry = ['k', number] | ['d', number] | ['i', Array<FileEntry>]
+15 -4
View File
@@ -16,7 +16,11 @@ export async function logoutUser() {
return data
}
export async function changePassword(username: string, passwordChange: string, password: string) {
export async function changePassword(
username: string,
passwordChange: string,
password: string
) {
const data = await Client.post(url_password, {
username,
passwordChange,
@@ -32,7 +36,11 @@ export async function listUsers() {
return data
}
export async function createUser(username: string, password?: string, privileged?: boolean) {
export async function createUser(
username: string,
password?: string,
privileged?: boolean
) {
const data = await Client.post(url_users, {
username,
password,
@@ -41,7 +49,10 @@ export async function createUser(username: string, password?: string, privileged
return data
}
export async function updateUser(username: string, changes: { password?: string, privileged?: boolean }) {
export async function updateUser(
username: string,
changes: { password?: string; privileged?: boolean }
) {
const data = await Client.put(`${url_users}/${username}`, changes)
return data
}
@@ -63,7 +74,7 @@ export async function updateServerName(name: string) {
export async function getServerConfig() {
const data = await Client.get('/api/config')
return data as { name: string, public: boolean }
return data as { name: string; public: boolean }
}
export const url_tokens = '/api/tokens'
+21 -17
View File
@@ -1,6 +1,6 @@
import { useMainStore } from "@/stores/main"
import { showAuthIframe, AuthCancelledError, isAuthIframeOpen } from 'paskia'
import type { FileEntry, UpdateEntry, errorEvent } from "./Document"
import { useMainStore } from '@/stores/main'
import { AuthCancelledError, isAuthIframeOpen, showAuthIframe } from 'paskia'
import type { FileEntry, UpdateEntry, errorEvent } from './Document'
export const watchUrl = '/api/watch'
@@ -25,18 +25,22 @@ export const loadSession = () => {
console.log(`Loaded session with ${tree.length} items cached`)
return true
} catch (error) {
console.log("Loading session failed", error)
console.log('Loading session failed', error)
return false
}
}
const saveSession = () => {
localStorage["cista-files"] = JSON.stringify(tree)
localStorage['cista-files'] = JSON.stringify(tree)
}
export const connect = (path: string, handlers: Partial<Record<keyof WebSocketEventMap, any>>) => {
export const connect = (
path: string,
handlers: Partial<Record<keyof WebSocketEventMap, any>>
) => {
const webSocket = new WebSocket(new URL(path, location.origin.replace(/^http/, 'ws')))
for (const [event, handler] of Object.entries(handlers)) webSocket.addEventListener(event, handler)
for (const [event, handler] of Object.entries(handlers))
webSocket.addEventListener(event, handler)
return webSocket
}
@@ -51,7 +55,7 @@ async function handleWsAuthError(msg: any) {
// Stop reconnection attempts while showing auth dialog
awaitingAuth = true
store.authInProgress = true
store.error = '' // Clear any connection message
store.error = '' // Clear any connection message
if (watchTimeout !== null) {
clearTimeout(watchTimeout)
watchTimeout = null
@@ -89,9 +93,9 @@ export const watchConnect = () => {
wsWatch = connect(watchUrl, {
message: handleWatchMessage,
close: watchReconnect,
close: watchReconnect
})
wsWatch.addEventListener("message", event => {
wsWatch.addEventListener('message', event => {
if (store.connected) return
const msg = JSON.parse(event.data)
if ('error' in msg) {
@@ -103,7 +107,7 @@ export const watchConnect = () => {
}
return
}
if ("server" in msg) {
if ('server' in msg) {
console.log('Connected to backend', msg)
store.server = msg.server
store.connected = true
@@ -141,7 +145,7 @@ const watchReconnect = (event: MessageEvent) => {
return
}
if (store.connected) {
console.warn("Disconnected from server", event)
console.warn('Disconnected from server', event)
store.connected = false
store.error = 'Reconnecting...'
}
@@ -151,7 +155,6 @@ const watchReconnect = (event: MessageEvent) => {
watchTimeout = setTimeout(watchConnect, reconnDelay)
}
const handleWatchMessage = (event: MessageEvent) => {
const msg = JSON.parse(event.data)
switch (true) {
@@ -192,13 +195,14 @@ function handleUpdateMessage(updateData: { update: UpdateEntry[] }) {
if (action === 'k') {
newtree.push(...tree.slice(oidx, oidx + arg))
oidx += arg
}
else if (action === 'd') oidx += arg
} else if (action === 'd') oidx += arg
else if (action === 'i') newtree.push(...arg)
else console.log("Unknown update action", action, arg)
else console.log('Unknown update action', action, arg)
}
if (oidx != tree.length)
throw Error(`Tree update out of sync, number of entries mismatch: got ${oidx}, expected ${tree.length}, new tree ${newtree.length}`)
throw Error(
`Tree update out of sync, number of entries mismatch: got ${oidx}, expected ${tree.length}, new tree ${newtree.length}`
)
store.updateRoot(newtree)
tree = newtree
saveSession()
+1 -1
View File
@@ -1,5 +1,5 @@
import { createRouter, createWebHashHistory } from 'vue-router'
import ExplorerView from '@/views/ExplorerView.vue'
import { createRouter, createWebHashHistory } from 'vue-router'
const router = createRouter({
history: createWebHashHistory(import.meta.env.BASE_URL),
+53 -41
View File
@@ -1,11 +1,11 @@
import type { FileEntry, FUID, SelectedItems } from '@/repositories/Document'
import type { FUID, FileEntry, SelectedItems } from '@/repositories/Document'
import { Doc } from '@/repositories/Document'
import { defineStore, type StateTree } from 'pinia'
import { resumeWatching, watchConnect } from '@/repositories/WS'
import { collator } from '@/utils'
import { watchConnect, resumeWatching } from '@/repositories/WS'
import { sorted, type SortOrder } from '@/utils/docsort'
import { type SortOrder, sorted } from '@/utils/docsort'
import SearchWorker from '@/workers/searchWorker?worker'
import { getDocuments, setDocuments, documentRef } from './documentStore'
import { type StateTree, defineStore } from 'pinia'
import { documentRef, getDocuments, setDocuments } from './documentStore'
// Singleton search worker instance
let searchWorker: Worker | null = null
@@ -19,8 +19,8 @@ function getSearchWorker(): Worker {
if (!searchWorker) {
searchWorker = new SearchWorker()
// Set up message handler once
searchWorker.onmessage = (e) => {
if (!searchStore || e.data.id !== searchId) return // Stale result
searchWorker.onmessage = e => {
if (!searchStore || e.data.id !== searchId) return // Stale result
// Convert plain data back to Doc instances
const docs = e.data.docs.map((d: any) => new Doc(d))
@@ -34,7 +34,7 @@ function getSearchWorker(): Worker {
// Throttle rapid intermediate updates to reduce UI flicker
const now = performance.now()
if (!e.data.done && now - lastResultUpdate < 50) {
return // Skip intermediate update if too recent
return // Skip intermediate update if too recent
}
lastResultUpdate = now
@@ -73,13 +73,13 @@ export const useMainStore = defineStore('main', {
searchLoading: false,
_searchRouteTimer: null as ReturnType<typeof setTimeout> | null,
fileExplorer: null as any,
error: '' as string, // Permanent status message (e.g., "Reconnecting...")
toast: '' as string, // Temporary toast (auto-dismisses)
error: '' as string, // Permanent status message (e.g., "Reconnecting...")
toast: '' as string, // Temporary toast (auto-dismisses)
toastTimeout: null as ReturnType<typeof setTimeout> | null,
connected: false,
authInProgress: false,
cursor: '' as string,
server: {} as Record<string, any> & { public?: boolean, paskia?: boolean },
server: {} as Record<string, any> & { public?: boolean; paskia?: boolean },
dialog: '' as '' | 'settings' | 'usermgmt' | 'accessdenied' | 'tokens',
uprogress: {} as any,
dprogress: {} as any,
@@ -87,19 +87,19 @@ export const useMainStore = defineStore('main', {
gallery: false,
sortListing: '' as SortOrder,
sortFiltered: '' as SortOrder,
searchHotkey: '/', // Character shown for search hotkey (Slash key)
searchHotkey: '/' // Character shown for search hotkey (Slash key)
},
user: {
username: '' as string,
privileged: false as boolean,
isLoggedIn: false as boolean,
isLoggedIn: false as boolean
},
space: {
disk: 0,
free: 0,
used: 0,
storage: 0,
allocated: 0,
allocated: 0
}
}),
persist: {
@@ -114,7 +114,7 @@ export const useMainStore = defineStore('main', {
tree.selected = Array.from(tree.selected)
return JSON.stringify(tree)
}
},
}
},
actions: {
updateRoot(root: FileEntry[]) {
@@ -122,22 +122,26 @@ export const useMainStore = defineStore('main', {
let loc = [] as string[]
for (const [level, name, key, mtime, size, allocated, isfile] of root) {
loc = loc.slice(0, level - 1)
docs.push(new Doc({
name,
loc: level ? loc.join('/') : '/',
key,
size,
allocated,
mtime,
dir: !isfile,
}))
docs.push(
new Doc({
name,
loc: level ? loc.join('/') : '/',
key,
size,
allocated,
mtime,
dir: !isfile
})
)
loc.push(name)
}
// Store in non-reactive external storage
setDocuments(docs)
// Clear ghosts that now exist in the real list
const realPaths = new Set(docs.map(d => d.loc ? `${d.loc}/${d.name}` : d.name))
this.ghosts = this.ghosts.filter(g => !realPaths.has(g.loc ? `${g.loc}/${g.name}` : g.name))
const realPaths = new Set(docs.map(d => (d.loc ? `${d.loc}/${d.name}` : d.name)))
this.ghosts = this.ghosts.filter(
g => !realPaths.has(g.loc ? `${g.loc}/${g.name}` : g.name)
)
// Clear hidden paths that no longer exist (deletion confirmed)
for (const path of this.hiddenPaths.keys()) {
if (!realPaths.has(path)) this.hiddenPaths.delete(path)
@@ -224,14 +228,14 @@ export const useMainStore = defineStore('main', {
size: doc.size,
allocated: doc.allocated,
mtime: doc.mtime,
dir: doc.dir,
dir: doc.dir
}))
worker.postMessage({ type: 'update', documents: docData })
},
search(query: string, loc: string) {
const worker = getSearchWorker()
const id = ++searchId
searchStore = this // Store reference for worker callback
searchStore = this // Store reference for worker callback
// Update query immediately so watchers know we're handling this
this.query = query
@@ -264,7 +268,8 @@ export const useMainStore = defineStore('main', {
// Delay showing loading indicator to avoid flicker on fast searches
loadingTimer = setTimeout(() => {
if (searchId === id) { // Still the current search
if (searchId === id) {
// Still the current search
this.searchLoading = true
}
loadingTimer = null
@@ -296,7 +301,7 @@ export const useMainStore = defineStore('main', {
this.cursor = ''
},
async logout() {
console.log("Logout")
console.log('Logout')
try {
const res = await fetch('/auth/api/logout', { method: 'POST' })
if (!res.ok) {
@@ -326,25 +331,29 @@ export const useMainStore = defineStore('main', {
showSortToast(order: SortOrder | '') {
const labels: Record<string, string> = {
'': 'Folders first',
'name': 'Alphabetical order',
'modified': 'Newest first',
'size': 'Largest first',
name: 'Alphabetical order',
modified: 'Newest first',
size: 'Largest first'
}
this.showToast(labels[order] || order, 1200)
},
focusBreadcrumb() {
(document.querySelector('.breadcrumb') as HTMLAnchorElement).focus()
;(document.querySelector('.breadcrumb') as HTMLAnchorElement).focus()
},
cancelDownloads() {
location.reload() // FIXME
location.reload() // FIXME
},
cancelUploads() {
location.reload() // FIXME
},
location.reload() // FIXME
}
},
getters: {
sortOrder(): SortOrder { return this.query ? this.prefs.sortFiltered : this.prefs.sortListing },
isUserLogged(): boolean { return this.user.isLoggedIn },
sortOrder(): SortOrder {
return this.query ? this.prefs.sortFiltered : this.prefs.sortListing
},
isUserLogged(): boolean {
return this.user.isLoggedIn
},
/** Get documents count (triggers on docVersion change) */
documentCount(): number {
// Access docVersion to make this reactive
@@ -366,7 +375,7 @@ export const useMainStore = defineStore('main', {
missing: new Set(),
docs: {},
keys: [],
recursive: [],
recursive: []
}
for (const doc of docs) {
if (selected.has(doc.key)) {
@@ -384,7 +393,10 @@ export const useMainStore = defineStore('main', {
const nremove = base.loc.length
ret.recursive.push([base.name, basepath, base])
for (const doc of docs) {
if (doc.loc === basepath || doc.loc.startsWith(basepath) && doc.loc[basepath.length] === '/') {
if (
doc.loc === basepath ||
(doc.loc.startsWith(basepath) && doc.loc[basepath.length] === '/')
) {
const full = doc.loc ? `${doc.loc}/${doc.name}` : doc.name
const rel = full.slice(nremove)
ret.recursive.push([rel, full, doc])
+1 -1
View File
@@ -1,7 +1,7 @@
import { clearTree } from '@/repositories/WS'
import { defineStore } from 'pinia'
import { computed } from 'vue'
import { useMainStore } from './main'
import { clearTree } from '@/repositories/WS'
export const useSsoAuthStore = defineStore('ssoAuth', () => {
const isExternalAuth = computed(() => {
+4 -3
View File
@@ -1,13 +1,14 @@
import { useMainStore } from '@/stores/main'
import { getDocuments } from '@/stores/documentStore'
import { useMainStore } from '@/stores/main'
export const exists = (path: string[]) => {
const store = useMainStore()
// Access docVersion to make this reactive
void store.docVersion
const p = path.join('/')
return getDocuments().some(doc => (doc.loc ? `${doc.loc}/${doc.name}` : doc.name) === p)
return getDocuments().some(
doc => (doc.loc ? `${doc.loc}/${doc.name}` : doc.name) === p
)
}
/** Strip file extension intelligently (handles .tar.gz, name.with.dots.pdf, etc.) */
+52 -23
View File
@@ -26,26 +26,39 @@ export function formatUnixDate(t: number) {
const formatter = new Intl.RelativeTimeFormat('en', { numeric: 'auto' })
if (adiff <= 5000) return 'now'
if (adiff <= 60000) {
return formatter.format(Math.round(diff / 1000), 'second').replace(' ago', '').replaceAll(' ', '\u202F')
return formatter
.format(Math.round(diff / 1000), 'second')
.replace(' ago', '')
.replaceAll(' ', '\u202F')
}
if (adiff <= 3600000) {
return formatter.format(Math.round(diff / 60000), 'minute').replace('utes', '').replace('ute', '').replaceAll(' ', '\u202F')
return formatter
.format(Math.round(diff / 60000), 'minute')
.replace('utes', '')
.replace('ute', '')
.replaceAll(' ', '\u202F')
}
if (adiff <= 86400000) {
return formatter.format(Math.round(diff / 3600000), 'hour').replaceAll(' ', '\u202F')
return formatter
.format(Math.round(diff / 3600000), 'hour')
.replaceAll(' ', '\u202F')
}
if (adiff <= 604800000) {
return formatter.format(Math.round(diff / 86400000), 'day').replaceAll(' ', '\u202F')
return formatter
.format(Math.round(diff / 86400000), 'day')
.replaceAll(' ', '\u202F')
}
let d = date.toLocaleDateString('en-ie', {
weekday: 'short',
year: 'numeric',
month: 'short',
day: 'numeric'
}).replace("Sept", "Sep")
if (d.length === 14) d = d.replace(' ', ' \u2007') // dom < 10 alignment (add figure space)
d = d.replaceAll(' ', '\u202F').replace('\u202F', '\u00A0') // nobr spaces, thin w/ date but not weekday
d = d.slice(0, -4) + d.slice(-2) // Two digit year is enough
let d = date
.toLocaleDateString('en-ie', {
weekday: 'short',
year: 'numeric',
month: 'short',
day: 'numeric'
})
.replace('Sept', 'Sep')
if (d.length === 14) d = d.replace(' ', ' \u2007') // dom < 10 alignment (add figure space)
d = d.replaceAll(' ', '\u202F').replace('\u202F', '\u00A0') // nobr spaces, thin w/ date but not weekday
d = d.slice(0, -4) + d.slice(-2) // Two digit year is enough
return d
}
@@ -63,34 +76,50 @@ interface FileTypes {
const filetypes: FileTypes = {
video: ['avi', 'mkv', 'mov', 'mp4', 'webm'],
image: ['avif', 'gif', 'jpg', 'jpeg', 'png', 'webp', 'svg'],
pdf: ['pdf'],
pdf: ['pdf']
}
export function getFileType(name: string): string {
const dotIndex = name.lastIndexOf('.')
if (dotIndex === -1 || dotIndex === name.length - 1) return 'unknown'
const ext = name.slice(dotIndex + 1).toLowerCase()
return Object.keys(filetypes).find(type => filetypes[type]!.includes(ext)) || 'unknown'
return (
Object.keys(filetypes).find(type => filetypes[type]!.includes(ext)) || 'unknown'
)
}
// Prebuilt for fast & consistent sorting
export const collator = new Intl.Collator('en', { sensitivity: 'base', numeric: true, usage: 'search' })
export const collator = new Intl.Collator('en', {
sensitivity: 'base',
numeric: true,
usage: 'search'
})
// Preformat document names for faster search
export function haystackFormat(str: string) {
const based = str.normalize('NFKD').replace(/[\u0300-\u036f]/g, '').toLowerCase()
const based = str
.normalize('NFKD')
.replace(/[\u0300-\u036f]/g, '')
.toLowerCase()
return '^' + based + '$'
}
// Preformat search string for faster search
export function needleFormat(query: string) {
const based = query.normalize('NFKD').replace(/[\u0300-\u036f]/g, '').toLowerCase()
return {based, words: based.split(/\s+/)}
const based = query
.normalize('NFKD')
.replace(/[\u0300-\u036f]/g, '')
.toLowerCase()
return { based, words: based.split(/\s+/) }
}
// Test if haystack includes needle
export function localeIncludes(haystack: string, filter: { based: string, words: string[] }) {
const {based, words} = filter
return haystack.includes(based) || words && words.every(word => haystack.includes(word))
export function localeIncludes(
haystack: string,
filter: { based: string; words: string[] }
) {
const { based, words } = filter
return (
haystack.includes(based) || (words && words.every(word => haystack.includes(word)))
)
}
+23 -12
View File
@@ -18,12 +18,12 @@
</template>
<script setup lang="ts">
import { watchEffect, ref, computed, watch } from 'vue'
import { useMainStore } from '@/stores/main'
import FileExplorer from '@/components/FileExplorer.vue'
import { getDocuments } from '@/stores/documentStore'
import { useMainStore } from '@/stores/main'
import { collator } from '@/utils'
import { sorted, sortedGrouped } from '@/utils/docsort'
import FileExplorer from '@/components/FileExplorer.vue'
import { computed, ref, watch, watchEffect } from 'vue'
const store = useMainStore()
const fileExplorer = ref()
@@ -40,7 +40,7 @@ const folderPath = computed(() => props.path.join('/'))
watch(
() => [props.query, props.path.join('/')] as const,
([query, loc]) => {
if (store.query === query) return // Already searching this query
if (store.query === query) return // Already searching this query
store.search(query, loc)
},
{ immediate: true }
@@ -55,9 +55,14 @@ const documents = computed(() => {
// Access docVersion to make this reactive to document changes
void store.docVersion
const hidden = store.hiddenPaths
const docs = getDocuments().filter(doc => doc.loc === loc && !hidden.has(doc.loc ? `${doc.loc}/${doc.name}` : doc.name))
const docs = getDocuments().filter(
doc =>
doc.loc === loc && !hidden.has(doc.loc ? `${doc.loc}/${doc.name}` : doc.name)
)
// Overlay ghosts for this location (excluding hidden ones)
const ghosts = store.ghosts.filter(g => g.loc === loc && !hidden.has(g.loc ? `${g.loc}/${g.name}` : g.name))
const ghosts = store.ghosts.filter(
g => g.loc === loc && !hidden.has(g.loc ? `${g.loc}/${g.name}` : g.name)
)
// Merge: ghosts that don't conflict with real docs
const realNames = new Set(docs.map(d => d.name))
const merged = [...docs, ...ghosts.filter(g => !realNames.has(g.name))]
@@ -66,7 +71,9 @@ const documents = computed(() => {
// Search results from worker (also filter hidden)
const hidden = store.hiddenPaths
const docs = store.searchResults.filter(doc => !hidden.has(doc.loc ? `${doc.loc}/${doc.name}` : doc.name))
const docs = store.searchResults.filter(
doc => !hidden.has(doc.loc ? `${doc.loc}/${doc.name}` : doc.name)
)
// Custom sort override in effect? Use grouped sorting to keep folders together
const order = store.prefs.sortFiltered
@@ -81,11 +88,15 @@ watchEffect(() => {
})
// Only auto-switch gallery mode when entering a new folder or on initial file list load
watch([() => props.path.join('/'), () => store.documentCount], ([path, len], [oldPath, oldLen]) => {
// React to path change or initial document load (0 → non-zero)
if (path === oldPath && oldLen !== undefined && oldLen > 0) return
store.prefs.gallery = documents.value.some(d => d.previewable)
}, { immediate: true })
watch(
[() => props.path.join('/'), () => store.documentCount],
([path, len], [oldPath, oldLen]) => {
// React to path change or initial document load (0 → non-zero)
if (path === oldPath && oldLen !== undefined && oldLen > 0) return
store.prefs.gallery = documents.value.some(d => d.previewable)
},
{ immediate: true }
)
</script>
<style scoped>
+50 -24
View File
@@ -37,14 +37,14 @@ interface ResultMessage {
}
// Worker state
let recentDocuments: WorkerDoc[] = [] // Sorted by mtime descending
let recentDocuments: WorkerDoc[] = [] // Sorted by mtime descending
let currentSearchId = 0
// Search result cache - cleared when documents change
interface CacheEntry {
query: string // Normalized query string
results: WorkerDoc[] // Matched results (up to limit)
complete: boolean // True if search scanned all documents
query: string // Normalized query string
results: WorkerDoc[] // Matched results (up to limit)
complete: boolean // True if search scanned all documents
}
const searchCache: CacheEntry[] = []
const MAX_CACHE_SIZE = 10
@@ -53,11 +53,21 @@ const RESULT_LIMIT = 100
// Normalize string for search (remove diacritics, lowercase)
// Haystack adds ^ and $ markers to allow matching start/end of name
function normalizeHaystack(str: string): string {
return '^' + str.normalize('NFKD').replace(/[\u0300-\u036f]/g, '').toLowerCase() + '$'
return (
'^' +
str
.normalize('NFKD')
.replace(/[\u0300-\u036f]/g, '')
.toLowerCase() +
'$'
)
}
function normalizeQuery(str: string): string {
return str.normalize('NFKD').replace(/[\u0300-\u036f]/g, '').toLowerCase()
return str
.normalize('NFKD')
.replace(/[\u0300-\u036f]/g, '')
.toLowerCase()
}
// Test if document matches search query
@@ -143,8 +153,12 @@ async function performSearch(rawQuery: string, loc: string, searchId: number) {
// Slow path: scan all documents
const batchSize = 500
for (let i = 0; i < recentDocuments.length && results.length < RESULT_LIMIT; i += batchSize) {
if (currentSearchId !== searchId) return // Superseded
for (
let i = 0;
i < recentDocuments.length && results.length < RESULT_LIMIT;
i += batchSize
) {
if (currentSearchId !== searchId) return // Superseded
// Process batch
const end = Math.min(i + batchSize, recentDocuments.length)
@@ -175,7 +189,13 @@ async function performSearch(rawQuery: string, loc: string, searchId: number) {
}
// Post results to main thread
function postResults(docs: WorkerDoc[], query: string, loc: string, id: number, done: boolean) {
function postResults(
docs: WorkerDoc[],
query: string,
loc: string,
id: number,
done: boolean
) {
const sorted = sortResults(docs, query, loc)
postMessage({
type: 'results',
@@ -188,20 +208,21 @@ function postResults(docs: WorkerDoc[], query: string, loc: string, id: number,
// Sort results by relevance
function sortResults(docs: WorkerDoc[], query: string, loc: string): WorkerDoc[] {
const locsub = loc + '/'
return [...docs].sort((a, b) => (
// Current folder first
Number(b.loc === loc) - Number(a.loc === loc) ||
// Then subfolders
Number(b.loc.startsWith(locsub)) - Number(a.loc.startsWith(locsub)) ||
// Then by location
collator.compare(a.loc, b.loc) ||
// Folders before files
Number(b.dir) - Number(a.dir) ||
// Exact name match first
Number(b.name.includes(query)) - Number(a.name.includes(query)) ||
// Finally by name
collator.compare(a.name, b.name)
))
return [...docs].sort(
(a, b) =>
// Current folder first
Number(b.loc === loc) - Number(a.loc === loc) ||
// Then subfolders
Number(b.loc.startsWith(locsub)) - Number(a.loc.startsWith(locsub)) ||
// Then by location
collator.compare(a.loc, b.loc) ||
// Folders before files
Number(b.dir) - Number(a.dir) ||
// Exact name match first
Number(b.name.includes(query)) - Number(a.name.includes(query)) ||
// Finally by name
collator.compare(a.name, b.name)
)
}
// Handle incoming messages
@@ -220,7 +241,12 @@ self.onmessage = async (e: MessageEvent<IncomingMessage>) => {
await performSearch(msg.query, msg.loc, msg.id)
} else {
// Empty query - no results needed
postMessage({ type: 'results', docs: [], id: msg.id, done: true } as ResultMessage)
postMessage({
type: 'results',
docs: [],
id: msg.id,
done: true
} as ResultMessage)
}
}
}
+8 -8
View File
@@ -9,27 +9,27 @@
* FASTAPI_VUE_BACKEND_URL=http://localhost:8999 - Backend API URL for proxying
*/
const backendUrl = process.env.FASTAPI_VUE_BACKEND_URL || "http://localhost:8999"
const backendUrl = process.env.FASTAPI_VUE_BACKEND_URL || 'http://localhost:8999'
export default function fastapiVue({ paths = ["/api"] } = {}) {
export default function fastapiVue({ paths = ['/api'] } = {}) {
// Build proxy configuration for each path
const proxy = {}
for (const path of paths) {
proxy[path] = {
target: backendUrl,
changeOrigin: false,
ws: true,
ws: true
}
}
return {
name: "fastapi-vite",
name: 'fastapi-vite',
config: () => ({
server: { proxy },
build: {
outDir: "../cista/frontend-build",
emptyOutDir: true,
},
}),
outDir: '../cista/frontend-build',
emptyOutDir: true
}
})
}
}
+14 -16
View File
@@ -1,29 +1,29 @@
import { fileURLToPath, URL } from 'node:url'
import { URL, fileURLToPath } from 'node:url'
import fastapiVue from './vite-plugin-fastapi.js'
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import { defineConfig } from 'vite'
import Components from 'unplugin-vue-components/vite'
// @ts-ignore
import svgLoader from 'vite-svg-loader'
import Components from 'unplugin-vue-components/vite'
// https://vitejs.dev/config/
// Note: fastapiVue() handles proxy and build output (uses FASTAPI_VUE_BACKEND_URL env)
export default defineConfig({
plugins: [
fastapiVue({ paths: ["/api", "/auth", "/files", "/zip", "/preview"] }),
fastapiVue({ paths: ['/api', '/auth', '/files', '/zip', '/preview'] }),
vue(),
svgLoader(), // import svg files
Components(), // auto import components
svgLoader(), // import svg files
Components() // auto import components
],
css: {
preprocessorOptions: {
less: {
modifyVars: {},
javascriptEnabled: true,
},
},
javascriptEnabled: true
}
}
},
resolve: {
alias: {
@@ -35,11 +35,9 @@ export default defineConfig({
output: {
manualChunks: {
// Bundle all SVG icons into a single chunk
icons: [
'/src/assets/svg/index.ts',
],
},
},
},
},
icons: ['/src/assets/svg/index.ts']
}
}
}
}
})
+51 -1
View File
@@ -25,6 +25,7 @@ classifiers = [
requires-python = ">=3.11"
dependencies = [
"argon2-cffi>=25.1.0",
"aspose-words>=26.4.0",
"av>=15.0.0",
"blake3>=1.0.5",
"docopt-ng>=0.9.0",
@@ -115,8 +116,57 @@ filterwarnings = [
"ignore::DeprecationWarning",
]
[tool.ruff]
target-version = "py311"
[tool.ruff.lint]
extend-select = ["E402"]
select = ["ALL"]
ignore = [
"COM812", # formatter compatibility
"ISC001", # formatter compatibility
"ANN001", # legacy codebase: no full runtime annotation coverage yet
"ANN002", # legacy codebase: no full runtime annotation coverage yet
"ANN003", # legacy codebase: no full runtime annotation coverage yet
"ANN201", # legacy codebase: no full runtime annotation coverage yet
"ANN202", # legacy codebase: no full runtime annotation coverage yet
"ANN204", # legacy codebase: no full runtime annotation coverage yet
"ANN205", # legacy codebase: no full runtime annotation coverage yet
"ARG001", # framework and callback signatures commonly require unused args
"BLE001", # broad catch remains in boundary/proxy/error-handling paths
"C901", # legacy complexity; keep other correctness rules enabled
"D100", # legacy docs not yet standardized
"D101", # legacy docs not yet standardized
"D102", # legacy docs not yet standardized
"D103", # legacy docs not yet standardized
"D104", # legacy docs not yet standardized
"D105", # legacy docs not yet standardized
"D107", # legacy docs not yet standardized
"D200", # legacy docs not yet standardized
"D203", # avoid D203/D211 conflict under ALL selection
"D212", # legacy docs not yet standardized
"D213", # legacy docs not yet standardized
"D400", # legacy docs not yet standardized
"D401", # legacy docs not yet standardized
"D413", # legacy docs not yet standardized
"D415", # legacy docs not yet standardized
"E501", # existing long literals/log strings
"EM101", # exception-message style; low signal for this project
"EM102", # exception-message style; low signal for this project
"INP001", # scripts folder intentionally lacks package markers
"PLC0415", # lazy imports used to avoid startup/circular import issues
"PLR0911", # legacy complexity; keep other correctness rules enabled
"PLR0912", # legacy complexity; keep other correctness rules enabled
"PLR0913", # legacy complexity; keep other correctness rules enabled
"PLR0915", # legacy complexity; keep other correctness rules enabled
"PLR2004", # legacy comparisons use inline constants
"PLW0603", # module-level shared state exists in server runtime code
"SLF001", # cohesive modules occasionally need private-member access
"TRY002", # exception-class strictness too noisy on legacy handlers
"TRY003", # exception-message strictness too noisy on legacy handlers
"TRY004", # type-check strictness too noisy on legacy handlers
"TRY300", # stylistic try/else preference
"TRY301", # stylistic raise-in-try preference
]
isort.known-first-party = ["cista"]
per-file-ignores."tests/*" = ["S", "ANN", "D", "INP", "PLR2004"]
per-file-ignores."scripts/*" = ["T20"]