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:
|
||||
docker compose down
|
||||
|
||||
run-local:
|
||||
run-local: build-all
|
||||
bunx concurrently -n api,web "./dist/driveflow-allinone" "bun --cwd frontend dev"
|
||||
|
||||
run-backend:
|
||||
|
||||
@@ -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
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',
|
||||
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) => (
|
||||
|
||||
Reference in New Issue
Block a user