Add agent guide and improve drive navigation UX

This commit is contained in:
mixa
2026-03-02 23:14:01 +03:00
parent d6658100bd
commit 2fab944351
5 changed files with 252 additions and 33 deletions

148
AGENTS.md Normal file
View 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.

View File

@@ -24,7 +24,7 @@ up:
down:
docker compose down
run-local:
run-local: build-all
bunx concurrently -n api,web "./dist/driveflow-allinone" "bun --cwd frontend dev"
run-backend:

View File

@@ -2,9 +2,9 @@
<html lang="en">
<head>
<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" />
<title>frontend</title>
<title>FileZ</title>
</head>
<body>
<div id="root"></div>

10
frontend/public/filez.svg Normal file
View 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

View File

@@ -111,6 +111,8 @@ const dict: Record<Lang, Record<string, string>> = {
archives: 'Archives',
tagged: 'Tagged',
recent: 'Recent',
recentFolders: 'Recent folders',
clearHistory: 'Clear history',
tags: 'Tags',
addTag: 'Add tag',
openInBrowser: 'Open in browser',
@@ -191,6 +193,8 @@ const dict: Record<Lang, Record<string, string>> = {
archives: 'Архивы',
tagged: 'С тегами',
recent: 'Недавние',
recentFolders: 'Недавние папки',
clearHistory: 'Очистить историю',
tags: 'Теги',
addTag: 'Добавить тег',
openInBrowser: 'Открыть в браузере',
@@ -243,12 +247,16 @@ function fileExt(name: string): string {
return name.slice(i + 1).toLowerCase()
}
function parentPathOf(p: string): string {
const n = p.trim()
if (!n || n === '/') return '/'
const parts = n.split('/').filter(Boolean)
if (parts.length <= 1) return '/'
return '/' + parts.slice(0, -1).join('/')
function drivePathFromLocation(): string {
const sp = new URLSearchParams(window.location.search)
const q = sp.get('p') || '/'
const clean = ('/' + q).replace(/\/+/g, '/')
return clean === '//' ? '/' : clean
}
function driveUrl(p: string): string {
if (!p || p === '/') return '/drive'
return `/drive?p=${encodeURIComponent(p)}`
}
function fileKind(file: FileEntry): string {
@@ -371,16 +379,53 @@ export default function App() {
const fileInputRef = useRef<HTMLInputElement>(null)
const folderInputRef = 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 effectiveMode = useMemo(() => (user?.colorMode && user.colorMode !== 'auto' ? user.colorMode : detectMode()), [user?.colorMode])
const t = (k: string) => dict[lang][k] ?? k
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)
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(() => {
document.documentElement.dataset.theme = effectiveTheme
@@ -407,20 +452,6 @@ export default function App() {
return () => window.removeEventListener('keydown', onKey)
}, [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(() => {
void bootstrap()
}, [])
@@ -477,7 +508,10 @@ export default function App() {
try {
const me = await api<User>('/api/auth/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 {
setUser(null)
}
@@ -491,16 +525,39 @@ export default function App() {
}
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)
setRoute(next)
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)}`)
setPath(data.path)
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() {
@@ -1161,6 +1218,14 @@ export default function App() {
setPaintSelect({ active: true, value: 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) => {
const paths = selectedPaths[f.path] ? selectedList : [f.path]
setDraggingPaths(paths)
@@ -1213,11 +1278,7 @@ export default function App() {
<div className="min-w-0">
<div className="flex items-center gap-2">
<KindIcon file={f} />
{f.isDir ? (
<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>
)}
<p className="truncate text-left text-sm font-medium hover:underline">{f.name}</p>
</div>
<div className="mt-2 flex flex-wrap items-center gap-1">
{(f.tags ?? []).map((tag) => (