feat: favicon, sidebar tooltip, fix Meilisearch auth and test warnings

- Add SVG favicon using pine/paper design tokens; wire into index.html
- Add SidebarLabel component with ResizeObserver overflow detection and
  CSS-only tooltip (pine-deep bg, IBM Plex Mono, 0.08s fade, no delay)
- Fix Meilisearch invalid_api_key: default api key to None instead of ''
  so no Authorization header is sent when auth is disabled
- Stub ResizeObserver in test setup for jsdom compatibility
- Wrap async renders in act() and advance fake timers inside act() to
  eliminate all act() warnings in App.test.tsx
This commit is contained in:
Tianyang 2026-08-01 18:44:32 +08:00
parent 39761b4ce7
commit 08a1211341
7 changed files with 99 additions and 17 deletions

View File

@ -5,7 +5,7 @@ class Settings:
def __init__(self): def __init__(self):
self.storage_dir = os.environ.get("STORAGE_DIR", "/data/documents") self.storage_dir = os.environ.get("STORAGE_DIR", "/data/documents")
self.meilisearch_host = os.environ.get("MEILISEARCH_HOST", "http://localhost:7700") self.meilisearch_host = os.environ.get("MEILISEARCH_HOST", "http://localhost:7700")
self.meilisearch_api_key = os.environ.get("MEILISEARCH_API_KEY", "") self.meilisearch_api_key = os.environ.get("MEILISEARCH_API_KEY") or None
self.meilisearch_index = os.environ.get("MEILISEARCH_INDEX", "documents") self.meilisearch_index = os.environ.get("MEILISEARCH_INDEX", "documents")
self.cors_origins = os.environ.get("CORS_ORIGINS", "http://localhost:5173").split(",") self.cors_origins = os.environ.get("CORS_ORIGINS", "http://localhost:5173").split(",")

View File

@ -2,6 +2,7 @@
<html lang="zh-CN"> <html lang="zh-CN">
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<title>DocHub</title> <title>DocHub</title>
</head> </head>
<body> <body>

View File

@ -0,0 +1,17 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" width="32" height="32">
<!-- Parchment background -->
<rect width="32" height="32" rx="7" fill="#faf9f6"/>
<!-- Document shape in pine -->
<rect x="7" y="5" width="13" height="17" rx="2" fill="#2f5d50"/>
<!-- Folded corner -->
<path d="M20 5 L25 10 L20 10 Z" fill="#e7efec"/>
<rect x="20" y="5" width="5" height="5" rx="1" fill="#234840"/>
<path d="M20 5 L25 10 H20 Z" fill="#e7efec"/>
<!-- Text lines on doc -->
<rect x="9.5" y="13" width="8" height="1.5" rx="0.75" fill="#e7efec" opacity="0.85"/>
<rect x="9.5" y="16" width="6" height="1.5" rx="0.75" fill="#e7efec" opacity="0.6"/>
<rect x="9.5" y="19" width="7" height="1.5" rx="0.75" fill="#e7efec" opacity="0.6"/>
<!-- Magnifier circle -->
<circle cx="21" cy="23" r="5" fill="none" stroke="#2f5d50" stroke-width="2.5"/>
<line x1="24.8" y1="26.8" x2="27.5" y2="29.5" stroke="#2f5d50" stroke-width="2.5" stroke-linecap="round"/>
</svg>

After

Width:  |  Height:  |  Size: 979 B

View File

@ -1,4 +1,4 @@
import { useEffect, useState } from 'react'; import { useEffect, useRef, useState } from 'react';
import { listDocuments, type DocumentFile } from '../api/client'; import { listDocuments, type DocumentFile } from '../api/client';
interface FileExplorerProps { interface FileExplorerProps {
@ -37,6 +37,27 @@ export default function FileExplorer({ onSelectFile, selectedKey, refreshKey }:
); );
} }
function SidebarLabel({ text }: { text: string }) {
const ref = useRef<HTMLSpanElement>(null);
const [overflows, setOverflows] = useState(false);
useEffect(() => {
const el = ref.current;
if (!el) return;
const check = () => setOverflows(el.scrollWidth > el.clientWidth);
check();
const ro = new ResizeObserver(check);
ro.observe(el);
return () => ro.disconnect();
}, [text]);
return (
<span className={overflows ? 'sidebar-label has-tip' : 'sidebar-label'} data-tip={text}>
<span ref={ref} className="label-text">{text}</span>
</span>
);
}
function FileRow({ function FileRow({
file, file,
onSelectFile, onSelectFile,
@ -56,7 +77,7 @@ function FileRow({
<path d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8z" /> <path d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8z" />
<path d="M14 2v6h6" /> <path d="M14 2v6h6" />
</svg> </svg>
<span className="filename">{file.name}</span> <SidebarLabel text={file.name} />
</div> </div>
); );
} }
@ -96,7 +117,7 @@ function FolderNode({
<path d="M8 5v14l11-7z" /> <path d="M8 5v14l11-7z" />
</svg> </svg>
</span> </span>
<span className="foldername">{name}</span> <SidebarLabel text={name} />
</div> </div>
{expanded && ( {expanded && (
<div className="tree-children"> <div className="tree-children">

View File

@ -234,12 +234,6 @@ body {
min-width: 0; min-width: 0;
} }
.tree-item .foldername {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
}
.tree-item:hover { .tree-item:hover {
background: rgba(27, 27, 24, 0.045); background: rgba(27, 27, 24, 0.045);
} }
@ -278,10 +272,43 @@ body {
padding-left: 20px; padding-left: 20px;
} }
.file-row .filename { /* ---------- Sidebar label + tooltip ---------- */
.sidebar-label {
flex: 1; flex: 1;
min-width: 0;
position: relative;
}
.label-text {
display: block;
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
white-space: nowrap;
}
.sidebar-label.has-tip::after {
content: attr(data-tip);
position: fixed;
left: 282px;
transform: translateY(-50%);
top: var(--tip-y, 50%);
background: var(--pine-deep);
color: var(--paper);
font-family: 'IBM Plex Mono', monospace;
font-size: 11px;
font-weight: 400;
white-space: nowrap;
padding: 5px 9px;
border-radius: 5px;
pointer-events: none;
opacity: 0;
box-shadow: 0 4px 14px rgba(27, 27, 24, 0.18);
z-index: 200;
transition: opacity 0.08s;
}
.sidebar-label.has-tip:hover::after {
opacity: 1;
} }
.file-icon { .file-icon {

View File

@ -1,4 +1,4 @@
import { render, screen, fireEvent } from '@testing-library/react'; import { render, screen, fireEvent, act } from '@testing-library/react';
import { test, expect, beforeEach, afterEach, vi } from 'vitest'; import { test, expect, beforeEach, afterEach, vi } from 'vitest';
import App from '../src/App'; import App from '../src/App';
import userEvent from '@testing-library/user-event'; import userEvent from '@testing-library/user-event';
@ -39,8 +39,10 @@ afterEach(() => {
vi.restoreAllMocks(); vi.restoreAllMocks();
}); });
test('renders the DocHub heading', () => { test('renders the DocHub heading', async () => {
render(<App />); await act(async () => {
render(<App />);
});
expect(screen.getByRole('heading', { name: 'DocHub' })).toBeInTheDocument(); expect(screen.getByRole('heading', { name: 'DocHub' })).toBeInTheDocument();
}); });
@ -149,18 +151,25 @@ test('jumping to a search result clears the search input so it does not show sta
}); });
test('typing a query does not show a false "no results" message before the debounced search resolves', async () => { test('typing a query does not show a false "no results" message before the debounced search resolves', async () => {
vi.useFakeTimers();
vi.mocked(listDocuments).mockResolvedValue({ folders: [], files: [] }); vi.mocked(listDocuments).mockResolvedValue({ folders: [], files: [] });
vi.mocked(search).mockResolvedValue({ results: [] }); vi.mocked(search).mockResolvedValue({ results: [] });
render(<App />); // Settle the initial FileExplorer fetch before switching to fake timers.
await act(async () => {
render(<App />);
});
vi.useFakeTimers();
const searchInput = screen.getByRole('textbox', { name: '搜索文档' }); const searchInput = screen.getByRole('textbox', { name: '搜索文档' });
fireEvent.change(searchInput, { target: { value: '检索' } }); fireEvent.change(searchInput, { target: { value: '检索' } });
expect(screen.queryByTestId('search-no-results')).not.toBeInTheDocument(); expect(screen.queryByTestId('search-no-results')).not.toBeInTheDocument();
await vi.advanceTimersByTimeAsync(300); // Advance the debounce and flush the resulting state updates inside act.
await act(async () => {
await vi.advanceTimersByTimeAsync(300);
});
expect(screen.getByTestId('search-no-results')).toBeInTheDocument(); expect(screen.getByTestId('search-no-results')).toBeInTheDocument();

View File

@ -1 +1,8 @@
import '@testing-library/jest-dom/vitest'; import '@testing-library/jest-dom/vitest';
// jsdom doesn't implement ResizeObserver; provide a no-op stub for tests.
global.ResizeObserver = class ResizeObserver {
observe() {}
unobserve() {}
disconnect() {}
};