Add agent guide and improve drive navigation UX
This commit is contained in:
148
AGENTS.md
Normal file
148
AGENTS.md
Normal file
@@ -0,0 +1,148 @@
|
|||||||
|
# AGENTS.md
|
||||||
|
|
||||||
|
This file is for coding agents working in this repository.
|
||||||
|
Follow it as the default playbook for changes.
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
- Applies to the entire repository rooted at `filez/`.
|
||||||
|
- If a deeper-scoped AGENTS.md is added later, the deeper file wins for that subtree.
|
||||||
|
|
||||||
|
## Rules Discovery
|
||||||
|
|
||||||
|
- Checked for Cursor rules: no `.cursorrules` and no `.cursor/rules/*` found.
|
||||||
|
- Checked for Copilot instructions: no `.github/copilot-instructions.md` found.
|
||||||
|
- Therefore, use this file + existing code conventions.
|
||||||
|
|
||||||
|
## Repository Layout
|
||||||
|
|
||||||
|
- `backend/` — Go API server (single-module Go app).
|
||||||
|
- `frontend/` — Bun + Vite + React + TypeScript UI.
|
||||||
|
- `dist/` — built single binary output (generated).
|
||||||
|
- Root `Makefile` — primary task entrypoints.
|
||||||
|
- Root `.env` — runtime config (do not commit secrets).
|
||||||
|
|
||||||
|
## Core Commands
|
||||||
|
|
||||||
|
Run from repo root unless noted.
|
||||||
|
|
||||||
|
### Setup
|
||||||
|
|
||||||
|
- Frontend deps: `cd frontend && bun install --frozen-lockfile`
|
||||||
|
- Backend deps: `cd backend && go mod tidy`
|
||||||
|
|
||||||
|
### Build
|
||||||
|
|
||||||
|
- Full local build pipeline: `make build-all`
|
||||||
|
- Backend only: `cd backend && go build ./...`
|
||||||
|
- Frontend only: `cd frontend && bun run build`
|
||||||
|
|
||||||
|
### Run
|
||||||
|
|
||||||
|
- Local full stack (binary + frontend dev): `make run-local`
|
||||||
|
- Backend only: `make run-backend`
|
||||||
|
- Frontend only: `make run-frontend`
|
||||||
|
- Docker detached: `make up`
|
||||||
|
- Docker foreground: `make run-all`
|
||||||
|
- Stop docker stack: `make down`
|
||||||
|
|
||||||
|
### Lint / Format
|
||||||
|
|
||||||
|
- Frontend lint: `cd frontend && bun run lint`
|
||||||
|
- Backend format: `cd backend && gofmt -w main.go`
|
||||||
|
- If touching more files, format all changed Go files.
|
||||||
|
- Backend sanity check: `cd backend && go vet ./...` (optional but recommended for larger changes)
|
||||||
|
|
||||||
|
### Test
|
||||||
|
|
||||||
|
- Current state: no test files are present (`*_test.go` not found; no frontend test runner configured).
|
||||||
|
- Default verification:
|
||||||
|
- `cd backend && go build ./...`
|
||||||
|
- `cd frontend && bun run build`
|
||||||
|
|
||||||
|
### Single Test (when tests are added)
|
||||||
|
|
||||||
|
- Go single test by name:
|
||||||
|
- `cd backend && go test -run TestName ./...`
|
||||||
|
- Go single test in one package:
|
||||||
|
- `cd backend && go test -run TestName ./path/to/pkg`
|
||||||
|
- Frontend single test is not applicable until a test framework is introduced.
|
||||||
|
|
||||||
|
## Code Style: Global
|
||||||
|
|
||||||
|
- Keep changes minimal and focused.
|
||||||
|
- Prefer existing patterns over introducing new abstractions.
|
||||||
|
- Do not add dependencies unless required.
|
||||||
|
- Do not add comments for obvious code.
|
||||||
|
- Keep paths normalized and security-conscious (especially file paths).
|
||||||
|
|
||||||
|
## Backend (Go) Style
|
||||||
|
|
||||||
|
- Use `gofmt` formatting.
|
||||||
|
- Group imports in standard Go style.
|
||||||
|
- Use `camelCase` for private names, `PascalCase` for exported types.
|
||||||
|
- Handler naming convention: `handleXxx` methods on `*Server`.
|
||||||
|
- Prefer small input structs for JSON payloads near handler usage.
|
||||||
|
- Parse/validate input early and return fast on errors.
|
||||||
|
- Use helper response functions (`writeJSON`, `writeErr`) consistently.
|
||||||
|
- Use explicit HTTP status codes (400/401/403/404/429/500 as appropriate).
|
||||||
|
- Keep errors user-safe; avoid leaking internals in API responses.
|
||||||
|
- Log security-relevant actions with user/path context.
|
||||||
|
- Use `normalizePath` and existing guard helpers for all path operations.
|
||||||
|
- Preserve auth/session middleware boundaries; do not bypass them.
|
||||||
|
- DB changes:
|
||||||
|
- Add migrations in existing startup migration flow.
|
||||||
|
- Keep indexes aligned with query patterns.
|
||||||
|
|
||||||
|
## Frontend (React/TS) Style
|
||||||
|
|
||||||
|
- TypeScript strict mode is enabled; satisfy strict typing.
|
||||||
|
- `noUnusedLocals`/`noUnusedParameters` are enabled — remove dead code.
|
||||||
|
- Use functional components and hooks only.
|
||||||
|
- Keep naming explicit: `loadFiles`, `movePathsTo`, `openMarkdownEditor`, etc.
|
||||||
|
- Reuse UI primitives from `src/components/ui/*`.
|
||||||
|
- Keep className composition via existing utilities/patterns (`cn` where used).
|
||||||
|
- Preserve current no-semicolon style and existing quote style.
|
||||||
|
- Avoid introducing global state libraries; stay with local hook state unless needed.
|
||||||
|
- Keep RU/EN dictionary entries in sync when adding UI text.
|
||||||
|
- Prefer derived state via `useMemo` for computed lists/maps.
|
||||||
|
- Keep keyboard/mouse interaction behavior consistent with current UX patterns.
|
||||||
|
|
||||||
|
## Error Handling Conventions
|
||||||
|
|
||||||
|
- Backend:
|
||||||
|
- Validate request payloads and query params first.
|
||||||
|
- Return JSON errors via `writeErr`.
|
||||||
|
- Avoid panics; rely on recovery middleware as safety net only.
|
||||||
|
- Frontend:
|
||||||
|
- Use `api<T>()` helper for HTTP.
|
||||||
|
- Surface actionable errors via existing error state/UI.
|
||||||
|
- Keep optimistic UI limited; refresh server state after mutating actions.
|
||||||
|
|
||||||
|
## API / Data Conventions
|
||||||
|
|
||||||
|
- Auth is cookie-based JWT + refresh flow; keep that contract intact.
|
||||||
|
- User identity is username-based in API/UI.
|
||||||
|
- File entries use normalized absolute-style paths (`/foo/bar`).
|
||||||
|
- For multi-item operations, use batch endpoints when available.
|
||||||
|
|
||||||
|
## Security / Secrets
|
||||||
|
|
||||||
|
- Never commit `.env` secrets.
|
||||||
|
- `JWT_SECRET` must be strong random in real deployments.
|
||||||
|
- Respect host/CORS checks driven by env.
|
||||||
|
- Keep upload/path handling resistant to traversal and invalid paths.
|
||||||
|
|
||||||
|
## Agent Workflow Expectations
|
||||||
|
|
||||||
|
- Before finishing, run relevant build/lint commands.
|
||||||
|
- If tests are absent, explicitly report that and provide build verification.
|
||||||
|
- Update docs when behavior/config changes.
|
||||||
|
- Do not assume Docker-only workflows; local run paths are supported.
|
||||||
|
|
||||||
|
## Quick Verification Checklist
|
||||||
|
|
||||||
|
- Backend builds: `cd backend && go build ./...`
|
||||||
|
- Frontend builds: `cd frontend && bun run build`
|
||||||
|
- Frontend lint (if UI touched): `cd frontend && bun run lint`
|
||||||
|
- Ensure no accidental secret/config leakage in diffs.
|
||||||
2
Makefile
2
Makefile
@@ -24,7 +24,7 @@ up:
|
|||||||
down:
|
down:
|
||||||
docker compose down
|
docker compose down
|
||||||
|
|
||||||
run-local:
|
run-local: build-all
|
||||||
bunx concurrently -n api,web "./dist/driveflow-allinone" "bun --cwd frontend dev"
|
bunx concurrently -n api,web "./dist/driveflow-allinone" "bun --cwd frontend dev"
|
||||||
|
|
||||||
run-backend:
|
run-backend:
|
||||||
|
|||||||
@@ -2,9 +2,9 @@
|
|||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
<link rel="icon" type="image/svg+xml" href="/filez.svg" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>frontend</title>
|
<title>FileZ</title>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|||||||
10
frontend/public/filez.svg
Normal file
10
frontend/public/filez.svg
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" role="img" aria-label="FileZ">
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="g" x1="0" y1="0" x2="1" y2="1">
|
||||||
|
<stop offset="0" stop-color="#34d399"/>
|
||||||
|
<stop offset="1" stop-color="#0ea5e9"/>
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<rect x="6" y="6" width="52" height="52" rx="12" fill="#111827"/>
|
||||||
|
<path d="M18 20h28v6L26 44h20v6H18v-6l20-18H18z" fill="url(#g)"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 423 B |
@@ -111,6 +111,8 @@ const dict: Record<Lang, Record<string, string>> = {
|
|||||||
archives: 'Archives',
|
archives: 'Archives',
|
||||||
tagged: 'Tagged',
|
tagged: 'Tagged',
|
||||||
recent: 'Recent',
|
recent: 'Recent',
|
||||||
|
recentFolders: 'Recent folders',
|
||||||
|
clearHistory: 'Clear history',
|
||||||
tags: 'Tags',
|
tags: 'Tags',
|
||||||
addTag: 'Add tag',
|
addTag: 'Add tag',
|
||||||
openInBrowser: 'Open in browser',
|
openInBrowser: 'Open in browser',
|
||||||
@@ -191,6 +193,8 @@ const dict: Record<Lang, Record<string, string>> = {
|
|||||||
archives: 'Архивы',
|
archives: 'Архивы',
|
||||||
tagged: 'С тегами',
|
tagged: 'С тегами',
|
||||||
recent: 'Недавние',
|
recent: 'Недавние',
|
||||||
|
recentFolders: 'Недавние папки',
|
||||||
|
clearHistory: 'Очистить историю',
|
||||||
tags: 'Теги',
|
tags: 'Теги',
|
||||||
addTag: 'Добавить тег',
|
addTag: 'Добавить тег',
|
||||||
openInBrowser: 'Открыть в браузере',
|
openInBrowser: 'Открыть в браузере',
|
||||||
@@ -243,12 +247,16 @@ function fileExt(name: string): string {
|
|||||||
return name.slice(i + 1).toLowerCase()
|
return name.slice(i + 1).toLowerCase()
|
||||||
}
|
}
|
||||||
|
|
||||||
function parentPathOf(p: string): string {
|
function drivePathFromLocation(): string {
|
||||||
const n = p.trim()
|
const sp = new URLSearchParams(window.location.search)
|
||||||
if (!n || n === '/') return '/'
|
const q = sp.get('p') || '/'
|
||||||
const parts = n.split('/').filter(Boolean)
|
const clean = ('/' + q).replace(/\/+/g, '/')
|
||||||
if (parts.length <= 1) return '/'
|
return clean === '//' ? '/' : clean
|
||||||
return '/' + parts.slice(0, -1).join('/')
|
}
|
||||||
|
|
||||||
|
function driveUrl(p: string): string {
|
||||||
|
if (!p || p === '/') return '/drive'
|
||||||
|
return `/drive?p=${encodeURIComponent(p)}`
|
||||||
}
|
}
|
||||||
|
|
||||||
function fileKind(file: FileEntry): string {
|
function fileKind(file: FileEntry): string {
|
||||||
@@ -371,16 +379,53 @@ export default function App() {
|
|||||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||||
const folderInputRef = useRef<HTMLInputElement>(null)
|
const folderInputRef = useRef<HTMLInputElement>(null)
|
||||||
const searchRef = useRef<HTMLInputElement>(null)
|
const searchRef = useRef<HTMLInputElement>(null)
|
||||||
|
const driveHistoryRef = useRef<string[]>(['/'])
|
||||||
|
const driveHistoryIndexRef = useRef(0)
|
||||||
|
|
||||||
const effectiveTheme = useMemo(() => user?.theme ?? 'dracula', [user?.theme])
|
const effectiveTheme = useMemo(() => user?.theme ?? 'dracula', [user?.theme])
|
||||||
const effectiveMode = useMemo(() => (user?.colorMode && user.colorMode !== 'auto' ? user.colorMode : detectMode()), [user?.colorMode])
|
const effectiveMode = useMemo(() => (user?.colorMode && user.colorMode !== 'auto' ? user.colorMode : detectMode()), [user?.colorMode])
|
||||||
const t = (k: string) => dict[lang][k] ?? k
|
const t = (k: string) => dict[lang][k] ?? k
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const onPop = () => setRoute(routeFromPath(window.location.pathname))
|
const onPop = () => {
|
||||||
|
const nextRoute = routeFromPath(window.location.pathname)
|
||||||
|
setRoute(nextRoute)
|
||||||
|
if (nextRoute === 'drive' && user) {
|
||||||
|
const nextPath = drivePathFromLocation()
|
||||||
|
const idx = driveHistoryRef.current.lastIndexOf(nextPath)
|
||||||
|
if (idx >= 0) {
|
||||||
|
driveHistoryIndexRef.current = idx
|
||||||
|
} else {
|
||||||
|
driveHistoryRef.current = [...driveHistoryRef.current, nextPath]
|
||||||
|
driveHistoryIndexRef.current = driveHistoryRef.current.length - 1
|
||||||
|
}
|
||||||
|
void loadFiles(nextPath, { syncUrl: false, trackHistory: false })
|
||||||
|
}
|
||||||
|
}
|
||||||
window.addEventListener('popstate', onPop)
|
window.addEventListener('popstate', onPop)
|
||||||
return () => window.removeEventListener('popstate', onPop)
|
return () => window.removeEventListener('popstate', onPop)
|
||||||
}, [])
|
}, [user])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const onAuxClick = (e: MouseEvent) => {
|
||||||
|
if (route !== 'drive' || !user) return
|
||||||
|
if (e.button === 3) {
|
||||||
|
e.preventDefault()
|
||||||
|
const nextIdx = driveHistoryIndexRef.current - 1
|
||||||
|
if (nextIdx < 0) return
|
||||||
|
driveHistoryIndexRef.current = nextIdx
|
||||||
|
void loadFiles(driveHistoryRef.current[nextIdx], { replace: true, trackHistory: false })
|
||||||
|
} else if (e.button === 4) {
|
||||||
|
e.preventDefault()
|
||||||
|
const nextIdx = driveHistoryIndexRef.current + 1
|
||||||
|
if (nextIdx >= driveHistoryRef.current.length) return
|
||||||
|
driveHistoryIndexRef.current = nextIdx
|
||||||
|
void loadFiles(driveHistoryRef.current[nextIdx], { replace: true, trackHistory: false })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
document.addEventListener('auxclick', onAuxClick, true)
|
||||||
|
return () => document.removeEventListener('auxclick', onAuxClick, true)
|
||||||
|
}, [route, user])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
document.documentElement.dataset.theme = effectiveTheme
|
document.documentElement.dataset.theme = effectiveTheme
|
||||||
@@ -407,20 +452,6 @@ export default function App() {
|
|||||||
return () => window.removeEventListener('keydown', onKey)
|
return () => window.removeEventListener('keydown', onKey)
|
||||||
}, [route, path])
|
}, [route, path])
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const onMouseUp = (e: MouseEvent) => {
|
|
||||||
if (route !== 'drive' || !user) return
|
|
||||||
if (e.button !== 3) return
|
|
||||||
e.preventDefault()
|
|
||||||
const up = parentPathOf(path)
|
|
||||||
if (up !== path) {
|
|
||||||
void loadFiles(up)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
window.addEventListener('mouseup', onMouseUp)
|
|
||||||
return () => window.removeEventListener('mouseup', onMouseUp)
|
|
||||||
}, [route, user, path])
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
void bootstrap()
|
void bootstrap()
|
||||||
}, [])
|
}, [])
|
||||||
@@ -477,7 +508,10 @@ export default function App() {
|
|||||||
try {
|
try {
|
||||||
const me = await api<User>('/api/auth/me')
|
const me = await api<User>('/api/auth/me')
|
||||||
setUser(me)
|
setUser(me)
|
||||||
await loadFiles('/')
|
const initial = routeFromPath(window.location.pathname) === 'drive' ? drivePathFromLocation() : '/'
|
||||||
|
driveHistoryRef.current = [initial]
|
||||||
|
driveHistoryIndexRef.current = 0
|
||||||
|
await loadFiles(initial, { syncUrl: routeFromPath(window.location.pathname) !== 'drive' })
|
||||||
} catch {
|
} catch {
|
||||||
setUser(null)
|
setUser(null)
|
||||||
}
|
}
|
||||||
@@ -491,16 +525,39 @@ export default function App() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function navigate(next: Route) {
|
function navigate(next: Route) {
|
||||||
const target = next === 'admin' ? '/admin' : next === 'drive' ? '/drive' : '/'
|
const target = next === 'admin' ? '/admin' : next === 'drive' ? driveUrl(path) : '/'
|
||||||
window.history.pushState(null, '', target)
|
window.history.pushState(null, '', target)
|
||||||
setRoute(next)
|
setRoute(next)
|
||||||
setErr('')
|
setErr('')
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadFiles(nextPath: string) {
|
async function loadFiles(nextPath: string, opts?: { syncUrl?: boolean; replace?: boolean; trackHistory?: boolean }) {
|
||||||
const data = await api<{ path: string; entries: FileEntry[] }>(`/api/files?path=${encodeURIComponent(nextPath)}`)
|
const data = await api<{ path: string; entries: FileEntry[] }>(`/api/files?path=${encodeURIComponent(nextPath)}`)
|
||||||
setPath(data.path)
|
setPath(data.path)
|
||||||
setFiles([...data.entries])
|
setFiles([...data.entries])
|
||||||
|
|
||||||
|
if (opts?.trackHistory !== false) {
|
||||||
|
const current = driveHistoryRef.current
|
||||||
|
const idx = driveHistoryIndexRef.current
|
||||||
|
const head = current.slice(0, idx + 1)
|
||||||
|
if (head[head.length - 1] !== data.path) {
|
||||||
|
head.push(data.path)
|
||||||
|
}
|
||||||
|
driveHistoryRef.current = head
|
||||||
|
driveHistoryIndexRef.current = head.length - 1
|
||||||
|
}
|
||||||
|
|
||||||
|
if (opts?.syncUrl !== false && route === 'drive') {
|
||||||
|
const target = driveUrl(data.path)
|
||||||
|
const current = window.location.pathname + window.location.search
|
||||||
|
if (current !== target) {
|
||||||
|
if (opts?.replace) {
|
||||||
|
window.history.replaceState(null, '', target)
|
||||||
|
} else {
|
||||||
|
window.history.pushState(null, '', target)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadUsers() {
|
async function loadUsers() {
|
||||||
@@ -1161,6 +1218,14 @@ export default function App() {
|
|||||||
setPaintSelect({ active: true, value: true })
|
setPaintSelect({ active: true, value: true })
|
||||||
setSelectedPaths((prev) => ({ ...prev, [f.path]: true }))
|
setSelectedPaths((prev) => ({ ...prev, [f.path]: true }))
|
||||||
}}
|
}}
|
||||||
|
onDoubleClick={(e) => {
|
||||||
|
if (isSelectionIgnoredTarget(e.target)) return
|
||||||
|
if (f.isDir) {
|
||||||
|
void loadFiles(f.path)
|
||||||
|
} else {
|
||||||
|
void openFile(f)
|
||||||
|
}
|
||||||
|
}}
|
||||||
onDragStart={(e) => {
|
onDragStart={(e) => {
|
||||||
const paths = selectedPaths[f.path] ? selectedList : [f.path]
|
const paths = selectedPaths[f.path] ? selectedList : [f.path]
|
||||||
setDraggingPaths(paths)
|
setDraggingPaths(paths)
|
||||||
@@ -1213,11 +1278,7 @@ export default function App() {
|
|||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<KindIcon file={f} />
|
<KindIcon file={f} />
|
||||||
{f.isDir ? (
|
<p className="truncate text-left text-sm font-medium hover:underline">{f.name}</p>
|
||||||
<button type="button" className="truncate text-left text-sm font-medium hover:underline" onClick={() => void loadFiles(f.path)}>{f.name}</button>
|
|
||||||
) : (
|
|
||||||
<button type="button" className="truncate text-left text-sm font-medium hover:underline" onClick={() => void openFile(f)}>{f.name}</button>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-2 flex flex-wrap items-center gap-1">
|
<div className="mt-2 flex flex-wrap items-center gap-1">
|
||||||
{(f.tags ?? []).map((tag) => (
|
{(f.tags ?? []).map((tag) => (
|
||||||
|
|||||||
Reference in New Issue
Block a user