Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 0 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -8,5 +8,4 @@
/apps/desktop/dist/
/.pre04-runs/
/docs/*
!/docs/renderer-architecture.md
/tsconfig.tsbuildinfo
2 changes: 1 addition & 1 deletion apps/desktop/package.json
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
{
"name": "@metis/desktop", "version": "0.2.4", "private": true,
"name": "@metis/desktop", "version": "0.2.5", "private": true,
"main": "dist/main/main.cjs"
}
73 changes: 61 additions & 12 deletions apps/desktop/src/renderer/app/main.tsx

Large diffs are not rendered by default.

185 changes: 183 additions & 2 deletions apps/desktop/src/renderer/app/styles/style.css
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,63 @@
--radius: 6px;
}

:root[data-theme="dark"], .app[data-theme="dark"] {
color-scheme: dark;
color: #e6edf3;
background: #0d1117;
--canvas: #0d1117;
--surface: #161b22;
--surface-raised: #1c2128;
--surface-hover: #21262d;
--surface-selected: #1f3547;
--border: #30363d;
--border-strong: #484f58;
--text: #e6edf3;
--muted: #8b949e;
--faint: #6e7681;
--primary: #161b22;
--primary-hover: #21262d;
--secondary: #58a6ff;
--secondary-soft: #1f3547;
--accent: #f2c75c;
--accent-strong: #d6a84b;
--accent-soft: #382c16;
--success: #3fb950;
--warning: #d29922;
--warning-soft: #382c16;
--danger: #f85149;
--shadow: 0 12px 32px rgba(0, 0, 0, .5);
}

:root[data-theme="sepia"], .app[data-theme="sepia"] {
color-scheme: light;
color: #463c2c;
background: #f7f0df;
--canvas: #f7f0df;
--surface: #eee3ca;
--surface-raised: #fbf7ee;
--surface-hover: #e5d8bc;
--surface-selected: #eddcb5;
--border: #cbbd9d;
--border-strong: #aa9368;
--text: #463c2c;
--muted: #765a2d;
--faint: #927546;
--primary: #3b2f1e;
--primary-hover: #4e3f2a;
--secondary: #8c5d26;
--secondary-soft: #eeddbb;
--accent: #b07d2b;
--accent-strong: #8c5d26;
--accent-soft: #fcf1d5;
--success: #3f7d52;
--warning: #9a6c28;
--warning-soft: #fff4cf;
--danger: #a84242;
--shadow: 0 12px 32px rgba(70, 60, 44, .18);
}


* { box-sizing: border-box; }
body { margin: 0; background: var(--canvas); }
button, input, select, textarea { font: inherit; }
Expand Down Expand Up @@ -70,17 +127,141 @@ main { flex: 1; min-width: 0; display: flex; flex-direction: column; background:
.source { flex: 1; overflow: auto; background: var(--surface-raised); }
.cm-editor { min-height: 100%; font-size: var(--document-font-size, 15px); color: var(--text); }
.cm-content { font-family: var(--document-font-family, "Cascadia Code", Consolas, monospace); padding: 28px 0 120px; line-height: 1.65; caret-color: var(--secondary); }
.cm-line { padding-left: 22px; padding-right: 32px; }
.cm-line { padding-left: 18px; padding-right: 32px; }
.cm-activeLine { background: rgba(20, 119, 131, .06) !important; }
.cm-selectionBackground { background: var(--secondary-soft) !important; }
.cm-gutters { background: var(--canvas) !important; border-right: 1px solid var(--border) !important; color: var(--faint) !important; }

/* CodeMirror Folding Gutter */
.cm-foldGutter { width: 14px; cursor: pointer; color: var(--faint); text-align: center; }
.cm-foldGutter:hover { color: var(--secondary); }
.cm-foldPlaceholder { background: var(--surface-hover); border: 1px solid var(--border); border-radius: 3px; padding: 0 5px; margin: 0 3px; color: var(--muted); font-size: 0.85em; cursor: pointer; }

/* CodeMirror Link Hover Indicator */
.cm-content.cm-ctrl-pressed .cm-link { cursor: pointer; text-decoration: underline; color: var(--secondary) !important; }

/* CodeMirror Hover Tooltips */
.cm-tooltip.cm-tooltip-asciidoc {
padding: 6px 10px;
background: var(--surface);
color: var(--text);
border: 1px solid var(--border);
border-radius: 6px;
box-shadow: 0 4px 14px rgba(0, 0, 0, 0.16);
font-size: 12px;
max-width: 320px;
line-height: 1.45;
pointer-events: none;
z-index: 100;
}
.cm-tooltip.cm-tooltip-asciidoc code {
color: var(--secondary);
background: var(--surface-hover);
padding: 1px 4px;
border-radius: 3px;
font-family: var(--document-font-family, monospace);
font-size: 0.9em;
}
.cm-tooltip.cm-tooltip-asciidoc .tooltip-title {
font-weight: 600;
margin-bottom: 2px;
}
.cm-tooltip.cm-tooltip-asciidoc .tooltip-body {
margin-bottom: 3px;
word-break: break-all;
}
.cm-tooltip.cm-tooltip-asciidoc .tooltip-meta {
font-size: 11px;
color: var(--muted);
}

/* Tab Context Menu */
.tab-context-menu {
position: fixed;
z-index: 1000;
background: var(--surface);
color: var(--text);
border: 1px solid var(--border);
border-radius: 6px;
box-shadow: 0 6px 18px rgba(0, 0, 0, 0.18);
padding: 4px;
min-width: 160px;
display: flex;
flex-direction: column;
gap: 1px;
}
.tab-context-menu button {
text-align: left;
background: none;
border: none;
padding: 6px 12px;
font-size: 13px;
color: var(--text);
border-radius: 4px;
cursor: pointer;
width: 100%;
}
.tab-context-menu button:hover:not(:disabled) {
background: var(--secondary-soft);
color: var(--secondary);
}
.tab-context-menu button:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.tab-context-menu .menu-divider {
height: 1px;
background: var(--border);
margin: 3px 0;
}

/* Dark Theme Specific Overrides */
.app[data-theme="dark"] .cm-gutters { background: var(--canvas) !important; border-right-color: var(--border) !important; color: var(--muted) !important; }
.app[data-theme="dark"] .cm-activeLine { background: rgba(88, 166, 255, .08) !important; }
.app[data-theme="dark"] .cm-selectionBackground { background: rgba(56, 139, 253, .3) !important; }
.app[data-theme="dark"] .modal form, .app[data-theme="dark"] .create-dialog { background: var(--surface-raised); color: var(--text); }
.app[data-theme="dark"] .ribbon-button { color: #8b949e; }
.app[data-theme="dark"] .ribbon-button:hover:not(:disabled), .app[data-theme="dark"] .ribbon-button.active { background: #21262d; color: var(--accent); }
.app[data-theme="dark"] .sidebar-tooltip { background: var(--surface); color: var(--text); border-color: var(--border); }
.app[data-theme="dark"] .sidebar-tooltip::before { background: var(--surface); }

/* Sepia Theme Specific Overrides */
.app[data-theme="sepia"] .modal form, .app[data-theme="sepia"] .create-dialog { background: var(--surface-raised); color: var(--text); }
.app[data-theme="sepia"] .ribbon-button { color: #765a2d; }
.app[data-theme="sepia"] .ribbon-button:hover:not(:disabled), .app[data-theme="sepia"] .ribbon-button.active { background: #eeddbb; color: var(--accent); }

footer {
min-height: 28px;
padding: 6px 14px;
border-top: 1px solid var(--border);
background: var(--surface);
font-size:calc(11px * var(--ui-font-scale, 1));
font-size: calc(11px * var(--ui-font-scale, 1));
color: var(--muted);
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.footer-status-text {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
flex: 1;
min-width: 0;
}
.footer-document-stats {
display: flex;
align-items: center;
gap: 8px;
flex-shrink: 0;
}
.footer-stat-badge {
padding: 2px 6px;
border-radius: 3px;
background: var(--surface-raised);
border: 1px solid var(--border);
font-size: calc(10px * var(--ui-font-scale, 1));
color: var(--muted);
}
[role=alert] { background: var(--warning-soft); color: var(--warning); padding: 12px 18px; }

17 changes: 13 additions & 4 deletions apps/desktop/src/renderer/features/relations/related.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,22 +9,31 @@ export function RelatedView({ index, selected, initialMode = 'list', open }: { i
const model = useMemo(() => relatedDocuments(index?.edges ?? [], selected, 12), [index, selected]);
const detail = model.documents.find(doc => doc.path === focused);
const names = [selected, ...model.documents.map(doc => doc.path)].filter(Boolean);
const position = (name: string, i: number) => positions[name] ?? (i === 0 ? { x: 450, y: 40 } : { x: 150 + ((i - 1) % 3) * 300, y: 145 + Math.floor((i - 1) / 3) * 95 });
const position = (name: string, i: number) => {
if (positions[name]) return positions[name];
if (i === 0) return { x: 450, y: 250 };
const count = Math.max(1, names.length - 1);
const angle = (2 * Math.PI * (i - 1)) / count - Math.PI / 2;
const radius = 170 + ((i % 2) * 35);
const x = Math.round(450 + radius * Math.cos(angle) * 1.4);
const y = Math.round(250 + radius * Math.sin(angle) * 0.95);
return { x: Math.max(80, Math.min(820, x)), y: Math.max(40, Math.min(460, y)) };
};
const move = (name: string, x: number, y: number) => setPositions(values => ({ ...values, [name]: { x: Math.max(100, Math.min(800, x)), y: Math.max(30, Math.min(470, y)) } }));
return <section aria-label="관련 문서"><h3>관련 문서</h3>
<div className="tools"><button aria-pressed={mode === 'list'} onClick={() => setMode('list')}>관련 목록</button><button aria-pressed={mode === 'graph'} onClick={() => setMode('graph')}>관계 그래프</button><button onClick={() => setPositions({})}>그래프 배치 초기화</button></div>
<p>직접 참조·포함과 공통 참조를 근거로 합니다. 문서 내용의 의미 유사도를 추론하지 않습니다.</p>
<p>직접 참조·포함과 공통 참조를 근거로 합니다. 더블 클릭하면 해당 문서를 엽니다.</p>
{!index ? <p>새 관계 조사 결과를 기다리고 있습니다.</p> : <>
{(model.truncated || index.partial) && <p role="note">일부 관계만 표시합니다. 관련 문서 최대 12개·문서별 근거 최대 5개입니다. 조사 범위 경고도 확인하세요.</p>}
{!model.documents.length && <p>확인된 관련 문서가 없습니다.</p>}
{mode === 'list' ? <ul>{model.documents.map(doc => <li key={doc.path}><button aria-pressed={focused === doc.path} onClick={() => setFocused(doc.path)}>관련 문서: {doc.path}</button></li>)}</ul> : <>
<p>실선: 직접 참조·포함 · 점선: 같은 대상을 참조하는 간접 관계. 노드를 선택하면 근거를 표시합니다. 드래그 또는 방향키는 화면 배치만 바꿉니다.</p>
<p>실선: 직접 참조·포함 · 점선: 같은 대상을 참조하는 간접 관계. 노드를 선택하면 근거를 표시하며 더블 클릭하면 바로 엽니다.</p>
<div className="relation-graph" aria-label="관련 문서 그래프">
<svg viewBox="0 0 900 500" preserveAspectRatio="none" aria-hidden="true">{model.documents.map((doc, i) => {
const center = position(selected, 0), node = position(doc.path, i + 1);
return <line key={doc.path} x1={center.x} y1={center.y} x2={node.x} y2={node.y} stroke="var(--secondary)" strokeWidth="2" strokeDasharray={doc.reasons.every(reason => reason.kind === 'common') ? '6 5' : undefined} />;
})}</svg>
{names.map((name, i) => { const point = position(name, i); return <button key={name} className="graph-node" style={{ left: `${point.x / 9}%`, top: `${point.y / 5}%` }} aria-label={`그래프 문서: ${name}`} aria-pressed={focused === name} onClick={() => setFocused(name)} onKeyDown={event => {
{names.map((name, i) => { const point = position(name, i); return <button key={name} className="graph-node" style={{ left: `${point.x / 9}%`, top: `${point.y / 5}%` }} aria-label={`그래프 문서: ${name}`} aria-pressed={focused === name} onClick={() => setFocused(name)} onDoubleClick={() => open({ relativePath: name, line: 1 })} onKeyDown={event => {
if (event.nativeEvent.isComposing || !['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown'].includes(event.key)) return;
event.preventDefault(); move(name, point.x + (event.key === 'ArrowLeft' ? -15 : event.key === 'ArrowRight' ? 15 : 0), point.y + (event.key === 'ArrowUp' ? -15 : event.key === 'ArrowDown' ? 15 : 0));
}} onPointerDown={event => { if (event.button !== 0) return; event.preventDefault(); event.currentTarget.focus(); event.currentTarget.setPointerCapture(event.pointerId); drag.current = { path: name, x: point.x, y: point.y, startX: event.clientX, startY: event.clientY }; }} onPointerMove={event => {
Expand Down
4 changes: 2 additions & 2 deletions apps/desktop/src/renderer/features/search/search.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ export function Search({ session, initialMode, memory, close, open, embedded = f
<p>저장된 .adoc 기준 · 미저장 편집 제외 · 외부 변경은 다시 검색하면 반영됩니다.</p>
<div className="search-controls"><label>검색 모드<select value={mode} onChange={event => setMode(event.target.value as SearchRequest['mode'])}><option value="text">본문</option><option value="files">파일 · 빠른 열기</option><option value="symbols">심볼 · 절</option></select></label>
<label><input type="checkbox" checked={caseSensitive} onChange={event => setCase(event.target.checked)} />대소문자 구분</label></div>
<label>검색어<input autoFocus={!embedded} type="search" maxLength={200} value={query} onChange={event => setQuery(event.target.value)} onKeyDown={event => { if (event.nativeEvent.isComposing || event.keyCode === 229) return; if (event.key === 'ArrowDown') { event.preventDefault(); container.current?.querySelector<HTMLButtonElement>('.search-result')?.focus(); } if (event.key === 'Enter') setRetry(value => value + 1); }} /></label>
<label>검색어<input autoFocus type="search" maxLength={200} value={query} onChange={event => setQuery(event.target.value)} onKeyDown={event => { if (event.nativeEvent.isComposing || event.keyCode === 229) return; if (event.key === 'ArrowDown') { event.preventDefault(); container.current?.querySelector<HTMLButtonElement>('.search-result')?.focus(); } if (event.key === 'Enter') setRetry(value => value + 1); }} /></label>
<div className="tools"><button onClick={() => setRetry(value => value + 1)}>다시 검색</button>{running && <button onClick={cancel}>검색 취소</button>}</div>
<div className="search-controls"><label>결과 경로 필터<input value={pathFilter} maxLength={200} onChange={event => setPathFilter(event.target.value)} /></label><label>결과 종류<select value={kindFilter} onChange={event => setKindFilter(event.target.value)}><option value="">전체</option>{Object.entries(kinds).map(([key, label]) => <option key={key} value={key}>{label}</option>)}</select></label><button onClick={() => { setPathFilter(''); setKindFilter(''); }}>필터 초기화</button></div>
<p>Escape로 검색어를 지우지 않고 창을 닫습니다. 글자 조합 중에는 창을 유지합니다. 필터는 받은 결과 안에서 적용됩니다. 검색어에서 아래 방향키로 결과 이동, 위·아래/Home/End로 선택, Enter로 열 수 있습니다.</p>
Expand All @@ -61,6 +61,6 @@ export function Search({ session, initialMode, memory, close, open, embedded = f
<strong>{kinds[hit.kind]} · {hit.label}</strong><small>{hit.relativePath}:{hit.line}</small><span>{hit.context}</span></button>)}</div></>}
</>;
return embedded
? <section className="search-panel" ref={node => { container.current = node; }} aria-labelledby="search-title">{content}</section>
? <section {...escape} className="search-panel" ref={node => { container.current = node; }} aria-labelledby="search-title">{content}</section>
: <dialog {...escape} className="search-dialog" ref={node => { container.current = node; }} aria-labelledby="search-title" onCancel={event => { event.preventDefault(); close(); }}>{content}</dialog>;
}
2 changes: 1 addition & 1 deletion apps/desktop/src/renderer/shared/lib/accessibility.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ export function useSearchDialogEscape(close: () => void) {
return {
onCompositionStartCapture: () => { composing.current = true; },
onCompositionEndCapture: () => { composing.current = false; },
onKeyDownCapture: (event: KeyboardEvent<HTMLDialogElement>) => {
onKeyDownCapture: (event: KeyboardEvent<HTMLElement>) => {
if (event.key !== 'Escape') return;
event.preventDefault();
event.stopPropagation();
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import { describe, it, expect } from 'vitest';
import { EditorState } from '@codemirror/state';
import { foldable } from '@codemirror/language';
import { asciidocFolding } from './asciidoc-language';

describe('asciidocFolding', () => {
it('folds section headings based on heading level', () => {
const doc = [
'= Document Title',
'Intro paragraph 1',
'Intro paragraph 2',
'== Section 1',
'Content for section 1',
'=== Sub Section 1.1',
'Sub content',
'== Section 2',
'Content for section 2'
].join('\n');

const state = EditorState.create({ doc, extensions: [asciidocFolding] });

// Title line (line 1, from 0 to 16)
const titleFold = foldable(state, 0, 16);
expect(titleFold).not.toBeNull();
expect(titleFold?.from).toBe(16);
expect(titleFold?.to).toBe(state.doc.length);

// Section 1 line (line 4)
const sec1Line = state.doc.line(4);
const sec1Fold = foldable(state, sec1Line.from, sec1Line.to);
expect(sec1Fold).not.toBeNull();
// Should fold up to the start of Section 2 (line 7 end)
const subContentLine = state.doc.line(7);
expect(sec1Fold?.to).toBe(subContentLine.to);

// Sub Section 1.1 (line 6)
const subLine = state.doc.line(6);
const subFold = foldable(state, subLine.from, subLine.to);
expect(subFold).not.toBeNull();
expect(subFold?.to).toBe(subContentLine.to);
});

it('folds delimited blocks like source and quote blocks', () => {
const doc = [
'= Title',
'[source,typescript]',
'----',
'const x = 1;',
'const y = 2;',
'----',
'After block'
].join('\n');

const state = EditorState.create({ doc, extensions: [asciidocFolding] });

const openDelimLine = state.doc.line(3);
const blockFold = foldable(state, openDelimLine.from, openDelimLine.to);
expect(blockFold).not.toBeNull();
const closeDelimLine = state.doc.line(6);
expect(blockFold?.to).toBe(closeDelimLine.to);
});
});
Loading
Loading