@agor/stores: - theme.svelte.ts, notifications.svelte.ts, health.svelte.ts extracted - Original files replaced with re-exports (zero consumer changes needed) - pnpm workspace + Vite/tsconfig aliases configured BackendAdapter tests (58 new): - backend-adapter.test.ts: 9 tests (lifecycle, singleton, testing seam) - tauri-adapter.test.ts: 28 tests (invoke mapping, command names, params) - electrobun-adapter.test.ts: 21 tests (RPC names, capabilities, stubs) Total: 523 tests passing (was 465, +58)
632 lines
18 KiB
Svelte
632 lines
18 KiB
Svelte
<script lang="ts">
|
|
import { appRpc } from './rpc.ts';
|
|
import CodeEditor from './CodeEditor.svelte';
|
|
import PdfViewer from './PdfViewer.svelte';
|
|
import CsvTable from './CsvTable.svelte';
|
|
|
|
interface Props {
|
|
cwd: string;
|
|
}
|
|
|
|
let { cwd }: Props = $props();
|
|
|
|
interface DirEntry {
|
|
name: string;
|
|
type: 'file' | 'dir';
|
|
size: number;
|
|
}
|
|
|
|
// Tree state: track loaded children and open/closed per path
|
|
let childrenCache = $state<Map<string, DirEntry[]>>(new Map());
|
|
let openDirs = $state<Set<string>>(new Set());
|
|
let loadingDirs = $state<Set<string>>(new Set());
|
|
let selectedFile = $state<string | null>(null);
|
|
|
|
// File viewer state
|
|
let fileContent = $state<string | null>(null);
|
|
let fileEncoding = $state<'utf8' | 'base64'>('utf8');
|
|
let fileSize = $state(0);
|
|
let fileError = $state<string | null>(null);
|
|
let fileLoading = $state(false);
|
|
let isDirty = $state(false);
|
|
let editorContent = $state('');
|
|
// Fix #6: Request token to discard stale file load responses
|
|
let fileRequestToken = 0;
|
|
// Feature 2: Track mtime at read time for conflict detection
|
|
let readMtimeMs = $state(0);
|
|
let showConflictDialog = $state(false);
|
|
|
|
// Extension-based type detection
|
|
const CODE_EXTS = new Set([
|
|
'ts', 'tsx', 'js', 'jsx', 'mjs', 'cjs',
|
|
'py', 'rs', 'go', 'css', 'scss', 'less',
|
|
'html', 'svelte', 'vue',
|
|
'json', 'md', 'yaml', 'yml', 'toml', 'sh', 'bash',
|
|
'xml', 'sql', 'c', 'cpp', 'h', 'java', 'php',
|
|
]);
|
|
const IMAGE_EXTS = new Set(['png', 'jpg', 'jpeg', 'gif', 'svg', 'webp', 'ico', 'bmp']);
|
|
const PDF_EXTS = new Set(['pdf']);
|
|
const CSV_EXTS = new Set(['csv', 'tsv']);
|
|
|
|
function getExt(name: string): string {
|
|
const dot = name.lastIndexOf('.');
|
|
return dot >= 0 ? name.slice(dot + 1).toLowerCase() : '';
|
|
}
|
|
|
|
type FileType = 'code' | 'image' | 'pdf' | 'csv' | 'text';
|
|
|
|
function detectFileType(name: string): FileType {
|
|
const ext = getExt(name);
|
|
if (PDF_EXTS.has(ext)) return 'pdf';
|
|
if (CSV_EXTS.has(ext)) return 'csv';
|
|
if (IMAGE_EXTS.has(ext)) return 'image';
|
|
if (CODE_EXTS.has(ext)) return 'code';
|
|
return 'text';
|
|
}
|
|
|
|
/** Map extension to CodeMirror language name. */
|
|
function extToLang(name: string): string {
|
|
const ext = getExt(name);
|
|
const map: Record<string, string> = {
|
|
ts: 'typescript', tsx: 'tsx', js: 'javascript', jsx: 'jsx',
|
|
mjs: 'javascript', cjs: 'javascript',
|
|
py: 'python', rs: 'rust', go: 'go',
|
|
css: 'css', scss: 'css', less: 'css',
|
|
html: 'html', svelte: 'html', vue: 'html',
|
|
json: 'json', md: 'markdown', yaml: 'yaml', yml: 'yaml',
|
|
toml: 'toml', sh: 'bash', bash: 'bash',
|
|
xml: 'xml', sql: 'sql', c: 'c', cpp: 'cpp', h: 'c',
|
|
java: 'java', php: 'php',
|
|
};
|
|
return map[ext] ?? 'text';
|
|
}
|
|
|
|
let selectedType = $derived<FileType>(selectedFile ? detectFileType(selectedFile) : 'text');
|
|
let selectedName = $derived(selectedFile ? selectedFile.split('/').pop() ?? '' : '');
|
|
|
|
/** Load directory entries via RPC. */
|
|
async function loadDir(dirPath: string) {
|
|
if (childrenCache.has(dirPath)) return;
|
|
const key = dirPath;
|
|
loadingDirs = new Set([...loadingDirs, key]);
|
|
try {
|
|
const result = await appRpc.request["files.list"]({ path: dirPath });
|
|
if (result.error) {
|
|
console.error(`[files.list] ${dirPath}: ${result.error}`);
|
|
return;
|
|
}
|
|
const next = new Map(childrenCache);
|
|
next.set(dirPath, result.entries);
|
|
childrenCache = next;
|
|
} catch (err) {
|
|
console.error('[files.list]', err);
|
|
} finally {
|
|
const s = new Set(loadingDirs);
|
|
s.delete(key);
|
|
loadingDirs = s;
|
|
}
|
|
}
|
|
|
|
/** Toggle a directory open/closed. Lazy-loads on first open. */
|
|
async function toggleDir(dirPath: string) {
|
|
const s = new Set(openDirs);
|
|
if (s.has(dirPath)) {
|
|
s.delete(dirPath);
|
|
openDirs = s;
|
|
} else {
|
|
s.add(dirPath);
|
|
openDirs = s;
|
|
await loadDir(dirPath);
|
|
}
|
|
}
|
|
|
|
/** Select and load a file. Fix #6: uses request token to discard stale responses. */
|
|
async function selectFile(filePath: string) {
|
|
if (selectedFile === filePath) return;
|
|
selectedFile = filePath;
|
|
isDirty = false;
|
|
fileContent = null;
|
|
fileError = null;
|
|
fileLoading = true;
|
|
const token = ++fileRequestToken;
|
|
|
|
const type = detectFileType(filePath);
|
|
|
|
// PDF uses its own loader via PdfViewer
|
|
if (type === 'pdf') {
|
|
fileLoading = false;
|
|
return;
|
|
}
|
|
|
|
// Images: read as base64 for display
|
|
if (type === 'image') {
|
|
try {
|
|
const result = await appRpc.request["files.read"]({ path: filePath });
|
|
if (token !== fileRequestToken) return;
|
|
if (result.error) {
|
|
fileError = result.error;
|
|
return;
|
|
}
|
|
fileContent = result.content ?? '';
|
|
fileEncoding = result.encoding;
|
|
fileSize = result.size;
|
|
} catch (err) {
|
|
if (token !== fileRequestToken) return;
|
|
fileError = err instanceof Error ? err.message : String(err);
|
|
} finally {
|
|
if (token === fileRequestToken) fileLoading = false;
|
|
}
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const result = await appRpc.request["files.read"]({ path: filePath });
|
|
if (token !== fileRequestToken) return;
|
|
if (result.error) {
|
|
fileError = result.error;
|
|
return;
|
|
}
|
|
fileContent = result.content ?? '';
|
|
fileEncoding = result.encoding;
|
|
fileSize = result.size;
|
|
editorContent = fileContent;
|
|
// Feature 2: Record mtime at read time
|
|
try {
|
|
const stat = await appRpc.request["files.stat"]({ path: filePath });
|
|
if (token === fileRequestToken && !stat.error) readMtimeMs = stat.mtimeMs;
|
|
} catch { /* non-critical */ }
|
|
} catch (err) {
|
|
if (token !== fileRequestToken) return;
|
|
fileError = err instanceof Error ? err.message : String(err);
|
|
} finally {
|
|
if (token === fileRequestToken) fileLoading = false;
|
|
}
|
|
}
|
|
|
|
/** Save current file. Feature 2: Check mtime before write for conflict detection. */
|
|
async function saveFile() {
|
|
if (!selectedFile || !isDirty) return;
|
|
try {
|
|
// Feature 2: Check if file was modified externally since we read it
|
|
if (readMtimeMs > 0) {
|
|
const stat = await appRpc.request["files.stat"]({ path: selectedFile });
|
|
if (!stat.error && stat.mtimeMs > readMtimeMs) {
|
|
showConflictDialog = true;
|
|
return;
|
|
}
|
|
}
|
|
await doSave();
|
|
} catch (err) {
|
|
console.error('[files.write]', err);
|
|
}
|
|
}
|
|
|
|
/** Force-save, bypassing conflict check. */
|
|
async function doSave() {
|
|
if (!selectedFile) return;
|
|
try {
|
|
const result = await appRpc.request["files.write"]({
|
|
path: selectedFile,
|
|
content: editorContent,
|
|
});
|
|
if (result.ok) {
|
|
isDirty = false;
|
|
fileContent = editorContent;
|
|
showConflictDialog = false;
|
|
// Update mtime after successful save
|
|
const stat = await appRpc.request["files.stat"]({ path: selectedFile });
|
|
if (!stat.error) readMtimeMs = stat.mtimeMs;
|
|
} else if (result.error) {
|
|
console.error('[files.write]', result.error);
|
|
}
|
|
} catch (err) {
|
|
console.error('[files.write]', err);
|
|
}
|
|
}
|
|
|
|
/** Reload file from disk (discard local changes). */
|
|
async function reloadFile() {
|
|
showConflictDialog = false;
|
|
if (selectedFile) {
|
|
isDirty = false;
|
|
const saved = selectedFile;
|
|
selectedFile = null;
|
|
await selectFile(saved);
|
|
}
|
|
}
|
|
|
|
function onEditorChange(newContent: string) {
|
|
editorContent = newContent;
|
|
isDirty = newContent !== fileContent;
|
|
}
|
|
|
|
function fileIcon(name: string): string {
|
|
const ext = getExt(name);
|
|
if (['ts', 'tsx', 'js', 'jsx', 'mjs', 'cjs'].includes(ext)) return '</>';
|
|
if (ext === 'svelte' || ext === 'vue') return '~';
|
|
if (ext === 'rs') return 'Rs';
|
|
if (ext === 'py') return 'Py';
|
|
if (ext === 'go') return 'Go';
|
|
if (['json', 'toml', 'yaml', 'yml'].includes(ext)) return '{}';
|
|
if (ext === 'md') return 'M';
|
|
if (ext === 'css' || ext === 'scss') return '#';
|
|
if (ext === 'html') return '<>';
|
|
if (IMAGE_EXTS.has(ext)) return 'Im';
|
|
if (ext === 'pdf') return 'Pd';
|
|
if (CSV_EXTS.has(ext)) return 'Tb';
|
|
return '..';
|
|
}
|
|
|
|
function formatSize(bytes: number): string {
|
|
if (bytes < 1024) return `${bytes} B`;
|
|
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
|
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
|
}
|
|
|
|
// Load root directory on mount
|
|
$effect(() => {
|
|
if (cwd) {
|
|
loadDir(cwd);
|
|
const s = new Set(openDirs);
|
|
s.add(cwd);
|
|
openDirs = s;
|
|
}
|
|
});
|
|
</script>
|
|
|
|
<div class="file-browser">
|
|
<!-- Tree panel -->
|
|
<div class="fb-tree">
|
|
{#snippet renderEntries(dirPath: string, depth: number)}
|
|
{#if childrenCache.has(dirPath)}
|
|
{#each childrenCache.get(dirPath) ?? [] as entry}
|
|
{@const fullPath = `${dirPath}/${entry.name}`}
|
|
{#if entry.type === 'dir'}
|
|
<button
|
|
class="fb-row fb-dir"
|
|
style:padding-left="{0.5 + depth * 0.875}rem"
|
|
onclick={() => toggleDir(fullPath)}
|
|
aria-expanded={openDirs.has(fullPath)}
|
|
>
|
|
<span class="fb-chevron" class:open={openDirs.has(fullPath)}>
|
|
{#if loadingDirs.has(fullPath)}...{:else}>{/if}
|
|
</span>
|
|
<span class="fb-name">{entry.name}</span>
|
|
</button>
|
|
{#if openDirs.has(fullPath)}
|
|
{@render renderEntries(fullPath, depth + 1)}
|
|
{/if}
|
|
{:else}
|
|
<button
|
|
class="fb-row fb-file"
|
|
class:selected={selectedFile === fullPath}
|
|
style:padding-left="{0.5 + depth * 0.875}rem"
|
|
onclick={() => selectFile(fullPath)}
|
|
title={`${entry.name} (${formatSize(entry.size)})`}
|
|
>
|
|
<span class="fb-icon file-type">{fileIcon(entry.name)}</span>
|
|
<span class="fb-name">{entry.name}</span>
|
|
{#if selectedFile === fullPath && isDirty}
|
|
<span class="dirty-dot" title="Unsaved changes"></span>
|
|
{/if}
|
|
</button>
|
|
{/if}
|
|
{/each}
|
|
{:else if loadingDirs.has(dirPath)}
|
|
<div class="fb-loading" style:padding-left="{0.5 + depth * 0.875}rem">Loading...</div>
|
|
{/if}
|
|
{/snippet}
|
|
|
|
{@render renderEntries(cwd, 0)}
|
|
</div>
|
|
|
|
<!-- Feature 2: Conflict dialog -->
|
|
{#if showConflictDialog}
|
|
<div class="conflict-overlay">
|
|
<div class="conflict-dialog">
|
|
<p class="conflict-title">File modified externally</p>
|
|
<p class="conflict-desc">This file was changed on disk since you opened it.</p>
|
|
<div class="conflict-actions">
|
|
<button class="conflict-btn overwrite" onclick={doSave}>Overwrite</button>
|
|
<button class="conflict-btn reload" onclick={reloadFile}>Reload</button>
|
|
<button class="conflict-btn cancel" onclick={() => showConflictDialog = false}>Cancel</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
{/if}
|
|
|
|
<!-- Viewer panel -->
|
|
<div class="fb-viewer">
|
|
{#if !selectedFile}
|
|
<div class="fb-empty">Select a file to view</div>
|
|
{:else if fileLoading}
|
|
<div class="fb-empty">Loading...</div>
|
|
{:else if fileError}
|
|
<div class="fb-error">{fileError}</div>
|
|
{:else if selectedType === 'pdf'}
|
|
<PdfViewer filePath={selectedFile} />
|
|
{:else if selectedType === 'csv' && fileContent != null}
|
|
<CsvTable content={fileContent} filename={selectedName} />
|
|
{:else if selectedType === 'image' && fileContent}
|
|
{@const ext = getExt(selectedName)}
|
|
{@const mime = ext === 'svg' ? 'image/svg+xml' : ext === 'png' ? 'image/png' : ext === 'gif' ? 'image/gif' : ext === 'webp' ? 'image/webp' : 'image/jpeg'}
|
|
<div class="fb-image-wrap">
|
|
<div class="fb-image-label">{selectedName} ({formatSize(fileSize)})</div>
|
|
<img
|
|
class="fb-image"
|
|
src="data:{mime};base64,{fileEncoding === 'base64' ? fileContent : btoa(fileContent)}"
|
|
alt={selectedName}
|
|
/>
|
|
</div>
|
|
{:else if selectedType === 'code' && fileContent != null}
|
|
<div class="fb-editor-header">
|
|
<span class="fb-editor-path" title={selectedFile}>
|
|
{selectedName}
|
|
{#if isDirty}<span class="dirty-indicator"> (modified)</span>{/if}
|
|
</span>
|
|
<span class="fb-editor-size">{formatSize(fileSize)}</span>
|
|
</div>
|
|
<CodeEditor
|
|
content={fileContent}
|
|
lang={extToLang(selectedFile)}
|
|
onsave={saveFile}
|
|
onchange={onEditorChange}
|
|
onblur={saveFile}
|
|
/>
|
|
{:else if fileContent != null}
|
|
<!-- Raw text fallback -->
|
|
<div class="fb-editor-header">
|
|
<span class="fb-editor-path" title={selectedFile}>
|
|
{selectedName}
|
|
{#if isDirty}<span class="dirty-indicator"> (modified)</span>{/if}
|
|
</span>
|
|
<span class="fb-editor-size">{formatSize(fileSize)}</span>
|
|
</div>
|
|
<CodeEditor
|
|
content={fileContent}
|
|
lang="text"
|
|
onsave={saveFile}
|
|
onchange={onEditorChange}
|
|
onblur={saveFile}
|
|
/>
|
|
{/if}
|
|
</div>
|
|
</div>
|
|
|
|
<style>
|
|
.file-browser {
|
|
display: flex;
|
|
height: 100%;
|
|
overflow: hidden;
|
|
font-size: 0.8125rem;
|
|
position: relative;
|
|
}
|
|
|
|
/* ── Tree panel ── */
|
|
.fb-tree {
|
|
width: 14rem;
|
|
min-width: 10rem;
|
|
overflow-y: auto;
|
|
padding: 0.25rem 0;
|
|
border-right: 1px solid var(--ctp-surface0);
|
|
background: var(--ctp-mantle);
|
|
flex-shrink: 0;
|
|
}
|
|
|
|
.fb-tree::-webkit-scrollbar { width: 0.25rem; }
|
|
.fb-tree::-webkit-scrollbar-track { background: transparent; }
|
|
.fb-tree::-webkit-scrollbar-thumb { background: var(--ctp-surface1); border-radius: 0.25rem; }
|
|
|
|
.fb-row {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 0.3rem;
|
|
width: 100%;
|
|
background: transparent;
|
|
border: none;
|
|
color: var(--ctp-text);
|
|
font-family: var(--ui-font-family);
|
|
font-size: 0.8125rem;
|
|
padding-top: 0.2rem;
|
|
padding-bottom: 0.2rem;
|
|
padding-right: 0.5rem;
|
|
cursor: pointer;
|
|
text-align: left;
|
|
white-space: nowrap;
|
|
transition: background 0.08s;
|
|
}
|
|
|
|
.fb-row:hover { background: var(--ctp-surface0); }
|
|
|
|
.fb-file.selected {
|
|
background: color-mix(in srgb, var(--accent, var(--ctp-mauve)) 15%, transparent);
|
|
color: var(--accent, var(--ctp-mauve));
|
|
}
|
|
|
|
.fb-chevron {
|
|
display: inline-block;
|
|
width: 0.875rem;
|
|
font-size: 0.75rem;
|
|
color: var(--ctp-overlay1);
|
|
transition: transform 0.12s;
|
|
transform: rotate(0deg);
|
|
flex-shrink: 0;
|
|
line-height: 1;
|
|
font-family: var(--term-font-family, monospace);
|
|
}
|
|
|
|
.fb-chevron.open { transform: rotate(90deg); }
|
|
|
|
.fb-icon { flex-shrink: 0; font-style: normal; }
|
|
|
|
.file-type {
|
|
font-size: 0.6rem;
|
|
color: var(--ctp-overlay1);
|
|
font-family: var(--term-font-family, monospace);
|
|
width: 1.25rem;
|
|
text-align: center;
|
|
}
|
|
|
|
.fb-name {
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
white-space: nowrap;
|
|
}
|
|
|
|
.fb-dir .fb-name { color: var(--ctp-subtext1); font-weight: 500; }
|
|
|
|
.fb-loading {
|
|
font-size: 0.7rem;
|
|
color: var(--ctp-overlay0);
|
|
font-style: italic;
|
|
padding: 0.15rem 0;
|
|
}
|
|
|
|
.dirty-dot {
|
|
width: 6px;
|
|
height: 6px;
|
|
border-radius: 50%;
|
|
background: var(--ctp-peach);
|
|
flex-shrink: 0;
|
|
margin-left: auto;
|
|
}
|
|
|
|
/* ── Viewer panel ── */
|
|
.fb-viewer {
|
|
flex: 1;
|
|
display: flex;
|
|
flex-direction: column;
|
|
overflow: hidden;
|
|
background: var(--ctp-base);
|
|
}
|
|
|
|
.fb-empty {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
height: 100%;
|
|
color: var(--ctp-overlay0);
|
|
font-size: 0.8rem;
|
|
font-style: italic;
|
|
}
|
|
|
|
.fb-error {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
height: 100%;
|
|
color: var(--ctp-red);
|
|
font-size: 0.8rem;
|
|
padding: 1rem;
|
|
}
|
|
|
|
.fb-editor-header {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
padding: 0.25rem 0.625rem;
|
|
background: var(--ctp-mantle);
|
|
border-bottom: 1px solid var(--ctp-surface0);
|
|
flex-shrink: 0;
|
|
}
|
|
|
|
.fb-editor-path {
|
|
font-size: 0.7rem;
|
|
color: var(--ctp-subtext0);
|
|
font-family: var(--term-font-family, monospace);
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
white-space: nowrap;
|
|
}
|
|
|
|
.dirty-indicator {
|
|
color: var(--ctp-peach);
|
|
font-style: italic;
|
|
}
|
|
|
|
.fb-editor-size {
|
|
font-size: 0.65rem;
|
|
color: var(--ctp-overlay0);
|
|
font-variant-numeric: tabular-nums;
|
|
flex-shrink: 0;
|
|
margin-left: 0.5rem;
|
|
}
|
|
|
|
.fb-image-wrap {
|
|
display: flex;
|
|
flex-direction: column;
|
|
align-items: center;
|
|
justify-content: center;
|
|
height: 100%;
|
|
gap: 0.5rem;
|
|
}
|
|
|
|
.fb-image-label {
|
|
font-size: 0.75rem;
|
|
color: var(--ctp-subtext0);
|
|
font-family: var(--term-font-family, monospace);
|
|
flex-shrink: 0;
|
|
padding: 0.5rem;
|
|
}
|
|
|
|
.fb-image {
|
|
max-width: 100%;
|
|
max-height: calc(100% - 2rem);
|
|
object-fit: contain;
|
|
border-radius: 0.25rem;
|
|
}
|
|
|
|
/* Feature 2: Conflict dialog */
|
|
.conflict-overlay {
|
|
position: absolute;
|
|
inset: 0;
|
|
z-index: 50;
|
|
background: color-mix(in srgb, var(--ctp-crust) 70%, transparent);
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
}
|
|
|
|
.conflict-dialog {
|
|
background: var(--ctp-surface0);
|
|
border: 1px solid var(--ctp-surface1);
|
|
border-radius: 0.5rem;
|
|
padding: 1rem 1.25rem;
|
|
max-width: 20rem;
|
|
}
|
|
|
|
.conflict-title {
|
|
margin: 0 0 0.25rem;
|
|
font-size: 0.875rem;
|
|
font-weight: 600;
|
|
color: var(--ctp-peach);
|
|
}
|
|
|
|
.conflict-desc {
|
|
margin: 0 0 0.75rem;
|
|
font-size: 0.75rem;
|
|
color: var(--ctp-subtext0);
|
|
}
|
|
|
|
.conflict-actions {
|
|
display: flex;
|
|
gap: 0.375rem;
|
|
}
|
|
|
|
.conflict-btn {
|
|
flex: 1;
|
|
padding: 0.25rem 0.5rem;
|
|
border-radius: 0.25rem;
|
|
border: 1px solid var(--ctp-surface1);
|
|
background: var(--ctp-surface0);
|
|
color: var(--ctp-text);
|
|
font-family: var(--ui-font-family);
|
|
font-size: 0.6875rem;
|
|
font-weight: 600;
|
|
cursor: pointer;
|
|
}
|
|
|
|
.conflict-btn.overwrite { border-color: var(--ctp-red); color: var(--ctp-red); }
|
|
.conflict-btn.reload { border-color: var(--ctp-blue); color: var(--ctp-blue); }
|
|
.conflict-btn:hover { background: var(--ctp-surface1); }
|
|
</style>
|