summaryrefslogtreecommitdiff
path: root/src/renderer
diff options
context:
space:
mode:
Diffstat (limited to 'src/renderer')
-rw-r--r--src/renderer/index.html12
-rw-r--r--src/renderer/src/App.css1165
-rw-r--r--src/renderer/src/App.tsx248
-rw-r--r--src/renderer/src/components/Editor.tsx188
-rw-r--r--src/renderer/src/components/FileTree.tsx165
-rw-r--r--src/renderer/src/components/ModalProvider.tsx123
-rw-r--r--src/renderer/src/components/OverleafConnect.tsx171
-rw-r--r--src/renderer/src/components/PdfViewer.tsx391
-rw-r--r--src/renderer/src/components/StatusBar.tsx26
-rw-r--r--src/renderer/src/components/Terminal.tsx165
-rw-r--r--src/renderer/src/components/Toolbar.tsx75
-rw-r--r--src/renderer/src/hooks/useModal.ts77
-rw-r--r--src/renderer/src/main.tsx10
-rw-r--r--src/renderer/src/stores/appStore.ts139
14 files changed, 2955 insertions, 0 deletions
diff --git a/src/renderer/index.html b/src/renderer/index.html
new file mode 100644
index 0000000..1fb4779
--- /dev/null
+++ b/src/renderer/index.html
@@ -0,0 +1,12 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+ <meta charset="UTF-8" />
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
+ <title>ClaudeTeX</title>
+</head>
+<body>
+ <div id="root"></div>
+ <script type="module" src="./src/main.tsx"></script>
+</body>
+</html>
diff --git a/src/renderer/src/App.css b/src/renderer/src/App.css
new file mode 100644
index 0000000..a0fb0f9
--- /dev/null
+++ b/src/renderer/src/App.css
@@ -0,0 +1,1165 @@
+/* ── Reset & Base ─────────────────────────────────────────────── */
+
+* {
+ margin: 0;
+ padding: 0;
+ box-sizing: border-box;
+}
+
+:root {
+ --bg-primary: #FFF8E7;
+ --bg-secondary: #F5EDD6;
+ --bg-tertiary: #EDE5CE;
+ --bg-hover: #E8DFCA;
+ --bg-active: #D4C9A8;
+ --border: #D6CEBC;
+ --text-primary: #3B3228;
+ --text-secondary: #5C5040;
+ --text-muted: #8C8070;
+ --accent: #6B5B3E;
+ --accent-hover: #7D6B4E;
+ --accent-blue: #4A6FA5;
+ --danger: #C75643;
+ --success: #5B8A3C;
+ --warning: #B8860B;
+ --font-mono: "SF Mono", "Fira Code", "JetBrains Mono", "Cascadia Code", monospace;
+ --font-sans: -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif;
+ --radius: 6px;
+ --radius-sm: 4px;
+ --shadow-sm: 0 1px 3px rgba(59, 50, 40, 0.08);
+ --shadow-md: 0 4px 12px rgba(59, 50, 40, 0.1);
+}
+
+html, body, #root {
+ height: 100%;
+ overflow: hidden;
+ background: var(--bg-primary);
+ color: var(--text-primary);
+ font-family: var(--font-sans);
+ font-size: 13px;
+ -webkit-font-smoothing: antialiased;
+}
+
+::selection {
+ background: #B8D4E3;
+}
+
+::-webkit-scrollbar {
+ width: 7px;
+ height: 7px;
+}
+::-webkit-scrollbar-track {
+ background: transparent;
+}
+::-webkit-scrollbar-thumb {
+ background: var(--border);
+ border-radius: 4px;
+}
+::-webkit-scrollbar-thumb:hover {
+ background: var(--text-muted);
+}
+
+/* ── Welcome Screen ──────────────────────────────────────────── */
+
+.welcome-screen {
+ height: 100%;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ background: var(--bg-primary);
+}
+
+.welcome-drag-bar {
+ position: fixed;
+ top: 0;
+ left: 0;
+ right: 0;
+ height: 44px;
+ -webkit-app-region: drag;
+}
+
+.welcome-content {
+ text-align: center;
+}
+
+.welcome-content h1 {
+ font-size: 48px;
+ font-weight: 700;
+ letter-spacing: -1px;
+ margin-bottom: 8px;
+ color: var(--accent);
+}
+
+.welcome-content p {
+ color: var(--text-muted);
+ margin-bottom: 32px;
+ font-size: 15px;
+}
+
+/* ── Buttons ─────────────────────────────────────────────────── */
+
+.btn {
+ display: inline-block;
+ padding: 8px 20px;
+ border: none;
+ border-radius: var(--radius);
+ font-size: 14px;
+ font-weight: 500;
+ cursor: pointer;
+ transition: all 0.15s;
+ font-family: var(--font-sans);
+}
+
+.btn:disabled {
+ opacity: 0.4;
+ cursor: not-allowed;
+ pointer-events: none;
+}
+
+.btn-primary {
+ background: var(--accent);
+ color: #FFF8E7;
+}
+.btn-primary:hover {
+ background: var(--accent-hover);
+}
+
+.btn-secondary {
+ background: var(--bg-tertiary);
+ color: var(--text-primary);
+ border: 1px solid var(--border);
+}
+.btn-secondary:hover {
+ background: var(--bg-hover);
+}
+
+.btn-large {
+ padding: 12px 32px;
+ font-size: 15px;
+ margin: 8px;
+}
+
+/* ── App Layout ──────────────────────────────────────────────── */
+
+.app {
+ height: 100%;
+ display: flex;
+ flex-direction: column;
+}
+
+.main-content {
+ flex: 1;
+ overflow: hidden;
+}
+
+/* ── Resize Handles ──────────────────────────────────────────── */
+
+.resize-handle {
+ background: var(--border);
+ transition: background 0.15s;
+}
+.resize-handle:hover,
+.resize-handle[data-resize-handle-active] {
+ background: var(--accent);
+}
+.resize-handle-h {
+ width: 1px !important;
+}
+.resize-handle-v {
+ height: 1px !important;
+}
+
+/* ── Toolbar ─────────────────────────────────────────────────── */
+
+.toolbar {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ height: 44px;
+ padding: 0 12px;
+ background: var(--bg-secondary);
+ border-bottom: 1px solid var(--border);
+ -webkit-app-region: drag;
+ gap: 8px;
+}
+
+.toolbar-left, .toolbar-center, .toolbar-right {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ -webkit-app-region: no-drag;
+}
+
+.toolbar-left {
+ min-width: 160px;
+}
+
+.drag-region {
+ width: 68px;
+ -webkit-app-region: drag;
+}
+
+.project-name {
+ font-weight: 600;
+ font-size: 13px;
+ color: var(--text-secondary);
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ max-width: 150px;
+}
+
+.toolbar-btn {
+ padding: 4px 12px;
+ border: none;
+ border-radius: var(--radius-sm);
+ background: transparent;
+ color: var(--text-secondary);
+ font-size: 12px;
+ font-weight: 500;
+ cursor: pointer;
+ transition: all 0.15s;
+ white-space: nowrap;
+ font-family: var(--font-sans);
+}
+.toolbar-btn:hover {
+ background: var(--bg-hover);
+ color: var(--text-primary);
+}
+
+.toolbar-btn-primary {
+ background: var(--accent);
+ color: #FFF8E7;
+}
+.toolbar-btn-primary:hover {
+ background: var(--accent-hover);
+}
+.toolbar-btn-primary:disabled {
+ opacity: 0.6;
+ cursor: not-allowed;
+}
+.toolbar-btn-primary.compiling {
+ animation: pulse 1s infinite;
+}
+
+@keyframes pulse {
+ 0%, 100% { opacity: 1; }
+ 50% { opacity: 0.6; }
+}
+
+.toolbar-main-doc {
+ font-size: 11px;
+ color: var(--text-muted);
+ font-family: var(--font-mono);
+ max-width: 120px;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.toolbar-separator {
+ width: 1px;
+ height: 20px;
+ background: var(--border);
+ margin: 0 4px;
+}
+
+/* ── File Tree ───────────────────────────────────────────────── */
+
+.file-tree {
+ height: 100%;
+ display: flex;
+ flex-direction: column;
+ background: var(--bg-secondary);
+ user-select: none;
+}
+
+.file-tree-header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: 10px 12px;
+ font-size: 11px;
+ font-weight: 600;
+ color: var(--text-muted);
+ letter-spacing: 0.5px;
+ text-transform: uppercase;
+ border-bottom: 1px solid var(--border);
+}
+
+.file-tree-action {
+ width: 22px;
+ height: 22px;
+ border: none;
+ border-radius: var(--radius-sm);
+ background: transparent;
+ color: var(--text-muted);
+ font-size: 16px;
+ cursor: pointer;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+}
+.file-tree-action:hover {
+ background: var(--bg-hover);
+ color: var(--text-primary);
+}
+
+.file-tree-content {
+ flex: 1;
+ overflow-y: auto;
+ padding: 4px 0;
+}
+
+.file-tree-item {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ padding: 4px 8px;
+ cursor: pointer;
+ border-radius: 0;
+ font-size: 13px;
+ color: var(--text-secondary);
+ transition: background 0.1s;
+}
+.file-tree-item:hover {
+ background: var(--bg-hover);
+}
+.file-tree-item.active {
+ background: var(--bg-active);
+ color: var(--text-primary);
+}
+
+.file-icon {
+ font-size: 14px;
+ width: 18px;
+ text-align: center;
+ flex-shrink: 0;
+}
+
+.file-name {
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+
+.main-doc-badge {
+ margin-left: auto;
+ font-size: 9px;
+ font-weight: 600;
+ padding: 1px 5px;
+ border-radius: 3px;
+ background: var(--accent);
+ color: var(--bg-primary);
+ text-transform: uppercase;
+ letter-spacing: 0.3px;
+ flex-shrink: 0;
+}
+
+.file-tree-empty {
+ padding: 16px;
+ color: var(--text-muted);
+ text-align: center;
+ font-size: 13px;
+}
+
+/* ── Context Menu ────────────────────────────────────────────── */
+
+.context-menu {
+ position: fixed;
+ z-index: 1000;
+ background: var(--bg-primary);
+ border: 1px solid var(--border);
+ border-radius: var(--radius);
+ padding: 4px;
+ min-width: 160px;
+ box-shadow: var(--shadow-md);
+}
+
+.context-menu-item {
+ padding: 6px 12px;
+ font-size: 13px;
+ border-radius: var(--radius-sm);
+ cursor: pointer;
+ color: var(--text-secondary);
+}
+.context-menu-item:hover {
+ background: var(--bg-active);
+ color: var(--text-primary);
+}
+.context-menu-item.danger {
+ color: var(--danger);
+}
+.context-menu-item.danger:hover {
+ background: var(--danger);
+ color: white;
+}
+
+.context-menu-separator {
+ height: 1px;
+ background: var(--border);
+ margin: 4px 0;
+}
+
+/* ── Modal ────────────────────────────────────────────────────── */
+
+.modal-overlay {
+ position: fixed;
+ inset: 0;
+ z-index: 2000;
+ background: rgba(59, 50, 40, 0.4);
+ backdrop-filter: blur(2px);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ -webkit-app-region: no-drag;
+}
+
+.modal-box {
+ background: var(--bg-primary);
+ border: 1px solid var(--border);
+ border-radius: 10px;
+ padding: 24px;
+ min-width: 400px;
+ max-width: 500px;
+ box-shadow: 0 16px 48px rgba(59, 50, 40, 0.2);
+}
+
+.modal-title {
+ font-size: 15px;
+ font-weight: 600;
+ color: var(--text-primary);
+ margin-bottom: 16px;
+}
+
+.modal-input {
+ width: 100%;
+ padding: 8px 12px;
+ border: 1px solid var(--border);
+ border-radius: var(--radius);
+ background: var(--bg-secondary);
+ color: var(--text-primary);
+ font-size: 13px;
+ font-family: var(--font-mono);
+ outline: none;
+ transition: border-color 0.15s;
+}
+.modal-input:focus {
+ border-color: var(--accent);
+}
+
+.modal-message {
+ font-size: 13px;
+ color: var(--text-secondary);
+ margin-bottom: 4px;
+ line-height: 1.5;
+}
+
+.modal-message-mono {
+ font-family: var(--font-mono);
+ font-size: 12px;
+ white-space: pre-wrap;
+ word-break: break-all;
+ max-height: 200px;
+ overflow-y: auto;
+ background: var(--bg-secondary);
+ padding: 8px 12px;
+ border-radius: var(--radius-sm);
+}
+
+.modal-actions {
+ display: flex;
+ justify-content: flex-end;
+ gap: 8px;
+ margin-top: 16px;
+}
+
+.btn-danger {
+ background: var(--danger);
+ color: white;
+}
+.btn-danger:hover {
+ opacity: 0.9;
+}
+
+/* ── Overleaf Connect ─────────────────────────────────────────── */
+
+.overleaf-dialog {
+ background: var(--bg-primary);
+ border: 1px solid var(--border);
+ border-radius: 12px;
+ width: 480px;
+ max-height: 90vh;
+ overflow-y: auto;
+ box-shadow: 0 16px 48px rgba(59, 50, 40, 0.2);
+}
+
+.overleaf-header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: 20px 24px 0;
+}
+.overleaf-header h2 {
+ font-size: 18px;
+ font-weight: 600;
+ color: var(--text-primary);
+}
+.overleaf-close {
+ width: 28px;
+ height: 28px;
+ border: none;
+ border-radius: var(--radius-sm);
+ background: transparent;
+ color: var(--text-muted);
+ font-size: 16px;
+ cursor: pointer;
+}
+.overleaf-close:hover {
+ background: var(--bg-hover);
+ color: var(--text-primary);
+}
+
+.overleaf-steps {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ padding: 16px 24px;
+}
+.overleaf-step {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ font-size: 12px;
+ color: var(--text-muted);
+}
+.overleaf-step.active {
+ color: var(--accent);
+ font-weight: 600;
+}
+.overleaf-step.done {
+ color: var(--success);
+}
+.step-num {
+ width: 20px;
+ height: 20px;
+ border-radius: 50%;
+ background: var(--bg-tertiary);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ font-size: 11px;
+ font-weight: 600;
+}
+.overleaf-step.active .step-num {
+ background: var(--accent);
+ color: #FFF8E7;
+}
+.overleaf-step.done .step-num {
+ background: var(--success);
+ color: white;
+}
+.step-line {
+ flex: 1;
+ height: 1px;
+ background: var(--border);
+}
+
+.overleaf-body {
+ padding: 0 24px 24px;
+}
+
+.overleaf-label {
+ display: block;
+ font-size: 13px;
+ font-weight: 500;
+ color: var(--text-secondary);
+ margin-bottom: 6px;
+}
+
+.overleaf-help {
+ font-size: 12px;
+ color: var(--text-muted);
+ margin-top: 10px;
+ line-height: 1.6;
+}
+.overleaf-help ol {
+ margin: 4px 0;
+ padding-left: 18px;
+}
+.overleaf-help li {
+ margin: 2px 0;
+}
+
+.overleaf-link {
+ color: var(--accent-blue);
+ cursor: pointer;
+ text-decoration: underline;
+}
+.overleaf-link:hover {
+ color: var(--accent);
+}
+
+.overleaf-link-btn {
+ background: none;
+ border: none;
+ color: var(--accent-blue);
+ cursor: pointer;
+ text-decoration: underline;
+ font-size: 12px;
+ font-family: var(--font-sans);
+ padding: 0;
+}
+.overleaf-link-btn:hover {
+ color: var(--accent);
+}
+
+.overleaf-section-title {
+ font-size: 14px;
+ font-weight: 600;
+ color: var(--text-primary);
+ margin-bottom: 12px;
+ display: flex;
+ align-items: baseline;
+ gap: 8px;
+}
+
+.overleaf-saved-hint {
+ font-size: 11px;
+ font-weight: 400;
+ color: var(--text-muted);
+}
+
+.overleaf-auth-status {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: 8px 12px;
+ background: var(--bg-secondary);
+ border-radius: var(--radius-sm);
+ font-size: 12px;
+ color: var(--text-secondary);
+}
+
+.overleaf-id-preview {
+ margin-top: 8px;
+ font-size: 12px;
+ color: var(--success);
+}
+.overleaf-id-preview code {
+ font-family: var(--font-mono);
+ background: var(--bg-tertiary);
+ padding: 2px 6px;
+ border-radius: 3px;
+}
+
+.overleaf-note {
+ margin-top: 12px;
+ font-size: 11px;
+ color: var(--text-muted);
+ padding: 8px 10px;
+ background: var(--bg-secondary);
+ border-radius: var(--radius-sm);
+ line-height: 1.5;
+}
+
+.overleaf-checkbox {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ font-size: 13px;
+ color: var(--text-secondary);
+ margin-top: 10px;
+ cursor: pointer;
+}
+.overleaf-checkbox input {
+ accent-color: var(--accent);
+}
+
+.overleaf-error {
+ margin: 0 24px;
+ padding: 8px 12px;
+ background: #FDE8E8;
+ border: 1px solid #F5C6C6;
+ border-radius: var(--radius-sm);
+ color: var(--danger);
+ font-size: 12px;
+ white-space: pre-wrap;
+ line-height: 1.5;
+}
+
+.overleaf-cloning {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ padding: 24px 0;
+ gap: 16px;
+}
+
+.overleaf-spinner {
+ width: 32px;
+ height: 32px;
+ border: 3px solid var(--border);
+ border-top-color: var(--accent);
+ border-radius: 50%;
+ animation: spin 0.8s linear infinite;
+}
+
+@keyframes spin {
+ to { transform: rotate(360deg); }
+}
+
+.overleaf-log {
+ font-family: var(--font-mono);
+ font-size: 12px;
+ color: var(--text-muted);
+ text-align: center;
+ white-space: pre-wrap;
+}
+
+/* ── Editor ──────────────────────────────────────────────────── */
+
+.editor-panel {
+ height: 100%;
+ display: flex;
+ flex-direction: column;
+ background: var(--bg-primary);
+}
+
+.editor-empty {
+ height: 100%;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ background: var(--bg-primary);
+}
+
+.editor-empty-content,
+.pdf-empty-content {
+ text-align: center;
+ color: var(--text-muted);
+}
+
+.editor-empty-content p:first-child,
+.pdf-empty-content p:first-child {
+ font-size: 15px;
+ margin-bottom: 8px;
+}
+
+.shortcut-hint {
+ font-size: 12px;
+ color: var(--text-muted);
+ opacity: 0.7;
+}
+
+/* ── Tab Bar ─────────────────────────────────────────────────── */
+
+.tab-bar {
+ display: flex;
+ align-items: center;
+ height: 36px;
+ background: var(--bg-secondary);
+ border-bottom: 1px solid var(--border);
+ overflow-x: auto;
+ scrollbar-width: none;
+}
+.tab-bar::-webkit-scrollbar {
+ display: none;
+}
+
+.tab {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ height: 100%;
+ padding: 0 14px;
+ font-size: 12px;
+ color: var(--text-muted);
+ cursor: pointer;
+ border-right: 1px solid var(--border);
+ white-space: nowrap;
+ transition: all 0.1s;
+ position: relative;
+}
+.tab:hover {
+ color: var(--text-secondary);
+ background: var(--bg-hover);
+}
+.tab.active {
+ color: var(--text-primary);
+ background: var(--bg-primary);
+}
+.tab.active::after {
+ content: '';
+ position: absolute;
+ bottom: 0;
+ left: 0;
+ right: 0;
+ height: 2px;
+ background: var(--accent);
+}
+
+.tab-dot {
+ color: var(--warning);
+ margin-right: 2px;
+}
+
+.tab-close {
+ width: 18px;
+ height: 18px;
+ border: none;
+ border-radius: var(--radius-sm);
+ background: transparent;
+ color: var(--text-muted);
+ font-size: 14px;
+ cursor: pointer;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ opacity: 0;
+ transition: all 0.1s;
+}
+.tab:hover .tab-close {
+ opacity: 1;
+}
+.tab-close:hover {
+ background: var(--bg-hover);
+ color: var(--danger);
+}
+
+.editor-content {
+ flex: 1;
+ overflow: hidden;
+}
+.editor-content .cm-editor {
+ height: 100%;
+}
+
+/* ── PDF Viewer ──────────────────────────────────────────────── */
+
+.pdf-panel {
+ height: 100%;
+ display: flex;
+ flex-direction: column;
+ background: var(--bg-secondary);
+}
+
+.pdf-empty {
+ height: 100%;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ background: var(--bg-secondary);
+}
+
+.pdf-toolbar {
+ display: flex;
+ align-items: center;
+ height: 36px;
+ padding: 0 8px;
+ background: var(--bg-secondary);
+ border-bottom: 1px solid var(--border);
+ gap: 4px;
+}
+
+.pdf-tab {
+ padding: 4px 12px;
+ border: none;
+ border-radius: var(--radius-sm);
+ background: transparent;
+ color: var(--text-muted);
+ font-size: 12px;
+ font-weight: 500;
+ cursor: pointer;
+ font-family: var(--font-sans);
+}
+.pdf-tab:hover {
+ color: var(--text-secondary);
+}
+.pdf-tab.active {
+ background: var(--bg-hover);
+ color: var(--text-primary);
+}
+
+.pdf-toolbar-spacer {
+ flex: 1;
+}
+
+.pdf-scale {
+ font-size: 11px;
+ color: var(--text-muted);
+ min-width: 40px;
+ text-align: center;
+}
+
+.pdf-container {
+ flex: 1;
+ overflow: auto;
+ padding: 16px;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ gap: 8px;
+ background: #EDE5CE;
+}
+
+.pdf-page {
+ box-shadow: var(--shadow-md);
+ border-radius: 2px;
+ cursor: crosshair;
+}
+
+.pdf-error {
+ padding: 16px;
+ color: var(--danger);
+ text-align: center;
+}
+
+.compile-log {
+ flex: 1;
+ overflow: auto;
+ padding: 12px;
+ background: var(--bg-primary);
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+}
+.compile-log pre {
+ font-family: var(--font-mono);
+ font-size: 11px;
+ color: var(--text-muted);
+ white-space: pre-wrap;
+ word-break: break-all;
+}
+
+/* Log badges on tab */
+.log-badge {
+ display: inline-block;
+ font-size: 10px;
+ font-weight: 600;
+ padding: 1px 5px;
+ border-radius: 8px;
+ margin-left: 4px;
+ line-height: 1.4;
+}
+.log-badge-error {
+ background: var(--danger);
+ color: white;
+}
+.log-badge-warning {
+ background: var(--warning);
+ color: white;
+}
+
+/* Log filter bar */
+.log-filters {
+ display: flex;
+ gap: 2px;
+}
+.log-filter-btn {
+ padding: 2px 8px;
+ border: none;
+ border-radius: var(--radius-sm);
+ background: transparent;
+ color: var(--text-muted);
+ font-size: 11px;
+ font-family: var(--font-sans);
+ cursor: pointer;
+}
+.log-filter-btn:hover {
+ color: var(--text-secondary);
+}
+.log-filter-btn.active {
+ background: var(--bg-hover);
+ color: var(--text-primary);
+ font-weight: 500;
+}
+
+/* Log entries */
+.log-entries {
+ display: flex;
+ flex-direction: column;
+ gap: 6px;
+}
+.log-entry {
+ padding: 8px 10px;
+ border-radius: var(--radius);
+ border-left: 3px solid transparent;
+}
+.log-entry-error {
+ background: #FFF0EE;
+ border-left-color: var(--danger);
+}
+.log-entry-warning {
+ background: #FFF8E0;
+ border-left-color: var(--warning);
+}
+.log-entry-info {
+ background: var(--bg-secondary);
+ border-left-color: var(--accent-blue);
+}
+.log-entry-header {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ margin-bottom: 4px;
+}
+.log-level-badge {
+ font-size: 10px;
+ font-weight: 600;
+ padding: 1px 6px;
+ border-radius: 3px;
+ text-transform: uppercase;
+ letter-spacing: 0.3px;
+}
+.level-error {
+ background: var(--danger);
+ color: white;
+}
+.level-warning {
+ background: var(--warning);
+ color: white;
+}
+.level-info {
+ background: var(--accent-blue);
+ color: white;
+}
+.log-entry-clickable {
+ cursor: pointer;
+ transition: filter 0.1s;
+}
+.log-entry-clickable:hover {
+ filter: brightness(0.95);
+}
+.log-entry-clickable .log-entry-file {
+ text-decoration: underline;
+}
+.log-entry-file {
+ font-family: var(--font-mono);
+ font-size: 11px;
+ color: var(--accent-blue);
+}
+.log-entry-message {
+ font-family: var(--font-mono);
+ font-size: 12px;
+ color: var(--text-primary);
+ line-height: 1.5;
+ word-break: break-word;
+}
+.log-empty {
+ color: var(--text-muted);
+ text-align: center;
+ padding: 40px;
+}
+
+/* Raw log collapsible */
+.log-raw {
+ margin-top: auto;
+ border-top: 1px solid var(--border);
+ padding-top: 8px;
+}
+.log-raw summary {
+ cursor: pointer;
+ font-size: 11px;
+ color: var(--text-muted);
+ padding: 4px 0;
+ user-select: none;
+}
+.log-raw summary:hover {
+ color: var(--text-secondary);
+}
+.log-raw pre {
+ max-height: 300px;
+ overflow: auto;
+ margin-top: 8px;
+ padding: 8px;
+ background: var(--bg-secondary);
+ border-radius: var(--radius-sm);
+}
+
+/* ── Terminal ────────────────────────────────────────────────── */
+
+.terminal-panel {
+ height: 100%;
+ display: flex;
+ flex-direction: column;
+ background: #2D2A24;
+}
+
+.terminal-toolbar {
+ display: flex;
+ align-items: center;
+ height: 32px;
+ padding: 0 8px;
+ background: #252219;
+ border-bottom: 1px solid #3D3830;
+ gap: 4px;
+}
+
+.terminal-toolbar .pdf-tab {
+ color: #A09880;
+}
+.terminal-toolbar .pdf-tab:hover {
+ color: #C8BFA0;
+}
+.terminal-toolbar .pdf-tab.active {
+ background: #3D3830;
+ color: #E8DFC0;
+}
+
+.terminal-content {
+ flex: 1;
+ padding: 4px;
+ overflow: hidden;
+}
+
+.terminal-content .xterm {
+ height: 100%;
+}
+
+.quick-actions {
+ display: flex;
+ gap: 4px;
+}
+
+.quick-action-btn {
+ font-size: 11px !important;
+ padding: 2px 8px !important;
+ background: #3D3830 !important;
+ color: #A09880 !important;
+ border: 1px solid #4D4840 !important;
+}
+.quick-action-btn:hover {
+ border-color: #8B7D5E !important;
+ color: #E8DFC0 !important;
+}
+
+/* ── Status Bar ──────────────────────────────────────────────── */
+
+.status-bar {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ height: 24px;
+ padding: 0 12px;
+ background: var(--accent);
+ border-top: 1px solid var(--border);
+ font-size: 11px;
+ color: #F5EDD6;
+}
+
+.status-left, .status-right {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+}
+
+.status-compiling {
+ color: var(--warning);
+ animation: pulse 1s infinite;
+}
+
+.status-git {
+ color: #E8DFC0;
+}
diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx
new file mode 100644
index 0000000..a809ffe
--- /dev/null
+++ b/src/renderer/src/App.tsx
@@ -0,0 +1,248 @@
+import { useState, useEffect, useCallback } from 'react'
+import { PanelGroup, Panel, PanelResizeHandle } from 'react-resizable-panels'
+import { useAppStore } from './stores/appStore'
+import { showConfirm } from './hooks/useModal'
+import ModalProvider from './components/ModalProvider'
+import OverleafConnect from './components/OverleafConnect'
+import Toolbar from './components/Toolbar'
+import FileTree from './components/FileTree'
+import Editor from './components/Editor'
+import PdfViewer from './components/PdfViewer'
+import Terminal from './components/Terminal'
+import StatusBar from './components/StatusBar'
+
+export default function App() {
+ const {
+ projectPath,
+ setProjectPath,
+ setFiles,
+ showTerminal,
+ showFileTree,
+ setIsGitRepo,
+ setGitStatus,
+ setStatusMessage
+ } = useAppStore()
+
+ const refreshFiles = useCallback(async () => {
+ if (!projectPath) return
+ const files = await window.api.readDir(projectPath)
+ setFiles(files)
+ }, [projectPath, setFiles])
+
+ // Load project
+ useEffect(() => {
+ if (!projectPath) return
+
+ refreshFiles()
+ window.api.watchStart(projectPath)
+
+ // Check git status
+ window.api.gitStatus(projectPath).then(({ isGit, status }) => {
+ setIsGitRepo(isGit)
+ setGitStatus(status)
+ })
+
+ // Auto-detect main document if not set
+ if (!useAppStore.getState().mainDocument) {
+ window.api.findMainTex(projectPath).then((mainTex) => {
+ if (mainTex) {
+ useAppStore.getState().setMainDocument(mainTex)
+ setStatusMessage(`Main document: ${mainTex.split('/').pop()}`)
+ }
+ })
+ }
+
+ const unsub = window.api.onWatchChange(() => {
+ refreshFiles()
+ })
+
+ return () => {
+ unsub()
+ window.api.watchStop()
+ }
+ }, [projectPath, refreshFiles, setIsGitRepo, setGitStatus])
+
+ // Compile log listener
+ useEffect(() => {
+ const unsub = window.api.onCompileLog((log) => {
+ useAppStore.getState().appendCompileLog(log)
+ })
+ return unsub
+ }, [])
+
+ // Keyboard shortcuts
+ useEffect(() => {
+ const handler = (e: KeyboardEvent) => {
+ if (e.metaKey || e.ctrlKey) {
+ if (e.key === 's') {
+ e.preventDefault()
+ handleSave()
+ }
+ if (e.key === 'b') {
+ e.preventDefault()
+ handleCompile()
+ }
+ if (e.key === '`') {
+ e.preventDefault()
+ useAppStore.getState().toggleTerminal()
+ }
+ }
+ }
+ window.addEventListener('keydown', handler)
+ return () => window.removeEventListener('keydown', handler)
+ }, [])
+
+ const handleSave = async () => {
+ const { activeTab, fileContents } = useAppStore.getState()
+ if (!activeTab || !fileContents[activeTab]) return
+ await window.api.writeFile(activeTab, fileContents[activeTab])
+ useAppStore.getState().markModified(activeTab, false)
+ setStatusMessage('Saved')
+ }
+
+ const handleCompile = async () => {
+ const { activeTab, mainDocument } = useAppStore.getState()
+ const target = mainDocument || activeTab
+ if (!target || !target.endsWith('.tex')) return
+
+ useAppStore.getState().setCompiling(true)
+ useAppStore.getState().clearCompileLog()
+ setStatusMessage('Compiling...')
+
+ const result = await window.api.compile(target) as {
+ success: boolean; log: string; missingPackages?: string[]
+ }
+
+ console.log('[compile] result.success:', result.success, 'log length:', result.log?.length, 'missingPkgs:', result.missingPackages)
+
+ // Ensure compile log is populated (fallback if streaming events missed)
+ const storeLog = useAppStore.getState().compileLog
+ console.log('[compile] storeLog length:', storeLog?.length)
+ if (!storeLog && result.log) {
+ useAppStore.getState().appendCompileLog(result.log)
+ }
+
+ // Always try to load PDF BEFORE setting compiling=false
+ const pdfPath = await window.api.getPdfPath(target)
+ console.log('[compile] checking pdfPath:', pdfPath)
+ try {
+ const s = await window.api.fileStat(pdfPath)
+ console.log('[compile] PDF exists, size:', s.size)
+ useAppStore.getState().setPdfPath(pdfPath)
+ } catch (err) {
+ console.log('[compile] PDF not found:', err)
+ }
+
+ // Now signal compilation done
+ useAppStore.getState().setCompiling(false)
+
+ // Missing packages detected — offer to install
+ if (result.missingPackages && result.missingPackages.length > 0) {
+ const pkgs = result.missingPackages
+ const ok = await showConfirm(
+ 'Missing LaTeX Packages',
+ `The following packages are needed:\n\n${pkgs.join(', ')}\n\nInstall them now? (may require your password in terminal)`,
+ )
+ if (ok) {
+ setStatusMessage(`Installing ${pkgs.join(', ')}...`)
+ const installResult = await window.api.installTexPackages(pkgs)
+ if (installResult.success) {
+ setStatusMessage('Packages installed. Recompiling...')
+ handleCompile()
+ return
+ } else if (installResult.message === 'need_sudo') {
+ setStatusMessage('Need sudo — installing via terminal...')
+ useAppStore.getState().showTerminal || useAppStore.getState().toggleTerminal()
+ await window.api.ptyWrite(`sudo tlmgr install ${pkgs.join(' ')}\n`)
+ setStatusMessage('Enter your password in terminal, then recompile with Cmd+B')
+ return
+ } else {
+ setStatusMessage('Package install failed')
+ }
+ }
+ }
+
+ if (result.success) {
+ setStatusMessage('Compiled successfully')
+ } else {
+ setStatusMessage('Compilation had errors — check Log tab')
+ }
+ }
+
+ const [showOverleaf, setShowOverleaf] = useState(false)
+
+ const handleOpenProject = async () => {
+ const path = await window.api.openProject()
+ if (path) setProjectPath(path)
+ }
+
+ return (
+ <>
+ <ModalProvider />
+ {showOverleaf && (
+ <OverleafConnect
+ onConnected={(path) => {
+ setShowOverleaf(false)
+ setProjectPath(path)
+ }}
+ onCancel={() => setShowOverleaf(false)}
+ />
+ )}
+ {!projectPath ? (
+ <div className="welcome-screen">
+ <div className="welcome-drag-bar" />
+ <div className="welcome-content">
+ <h1>ClaudeTeX</h1>
+ <p>LaTeX editor with AI and Overleaf sync</p>
+ <button className="btn btn-primary btn-large" onClick={handleOpenProject}>
+ Open Project
+ </button>
+ <button className="btn btn-secondary btn-large" onClick={() => setShowOverleaf(true)}>
+ Clone from Overleaf
+ </button>
+ </div>
+ </div>
+ ) : (
+ <div className="app">
+ <Toolbar onCompile={handleCompile} onSave={handleSave} onOpenProject={handleOpenProject} />
+ <div className="main-content">
+ <PanelGroup direction="horizontal">
+ {showFileTree && (
+ <>
+ <Panel defaultSize={18} minSize={12} maxSize={35}>
+ <FileTree />
+ </Panel>
+ <PanelResizeHandle className="resize-handle resize-handle-h" />
+ </>
+ )}
+ <Panel minSize={30}>
+ <PanelGroup direction="vertical">
+ <Panel defaultSize={showTerminal ? 70 : 100} minSize={30}>
+ <PanelGroup direction="horizontal">
+ <Panel defaultSize={50} minSize={25}>
+ <Editor />
+ </Panel>
+ <PanelResizeHandle className="resize-handle resize-handle-h" />
+ <Panel defaultSize={50} minSize={20}>
+ <PdfViewer />
+ </Panel>
+ </PanelGroup>
+ </Panel>
+ {showTerminal && (
+ <>
+ <PanelResizeHandle className="resize-handle resize-handle-v" />
+ <Panel defaultSize={30} minSize={15} maxSize={60}>
+ <Terminal />
+ </Panel>
+ </>
+ )}
+ </PanelGroup>
+ </Panel>
+ </PanelGroup>
+ </div>
+ <StatusBar />
+ </div>
+ )}
+ </>
+ )
+}
diff --git a/src/renderer/src/components/Editor.tsx b/src/renderer/src/components/Editor.tsx
new file mode 100644
index 0000000..30a1e8b
--- /dev/null
+++ b/src/renderer/src/components/Editor.tsx
@@ -0,0 +1,188 @@
+import { useEffect, useRef, useCallback } from 'react'
+import { EditorView, keymap, lineNumbers, highlightActiveLine, highlightActiveLineGutter, drawSelection, rectangularSelection } from '@codemirror/view'
+import { EditorState } from '@codemirror/state'
+import { defaultKeymap, history, historyKeymap, indentWithTab } from '@codemirror/commands'
+import { bracketMatching, foldGutter, indentOnInput, StreamLanguage } from '@codemirror/language'
+import { closeBrackets, closeBracketsKeymap } from '@codemirror/autocomplete'
+import { searchKeymap, highlightSelectionMatches } from '@codemirror/search'
+import { stex } from '@codemirror/legacy-modes/mode/stex'
+import { useAppStore } from '../stores/appStore'
+
+// Cosmic Latte light theme
+const cosmicLatteTheme = EditorView.theme({
+ '&': {
+ height: '100%',
+ fontSize: '13.5px',
+ backgroundColor: '#FFF8E7'
+ },
+ '.cm-content': {
+ caretColor: '#3B3228',
+ fontFamily: '"SF Mono", "Fira Code", "JetBrains Mono", monospace',
+ color: '#3B3228',
+ padding: '8px 0'
+ },
+ '.cm-cursor': { borderLeftColor: '#3B3228' },
+ '.cm-activeLine': { backgroundColor: '#F5EDD6' },
+ '.cm-activeLineGutter': { backgroundColor: '#F5EDD6' },
+ '.cm-selectionBackground, ::selection': { backgroundColor: '#B8D4E3 !important' },
+ '.cm-gutters': {
+ backgroundColor: '#F5EDD6',
+ color: '#A09880',
+ border: 'none',
+ borderRight: '1px solid #D6CEBC',
+ paddingRight: '8px'
+ },
+ '.cm-lineNumbers .cm-gutterElement': { padding: '0 8px' },
+ '.cm-foldGutter': { width: '16px' },
+ '.cm-matchingBracket': { backgroundColor: '#D4C9A8', outline: 'none' },
+ // LaTeX syntax colors — warm earthy palette on Cosmic Latte
+ '.cm-keyword': { color: '#8B2252' }, // commands: \begin, \section
+ '.cm-atom': { color: '#B8860B' }, // constants
+ '.cm-string': { color: '#5B8A3C' }, // strings / text args
+ '.cm-comment': { color: '#A09880', fontStyle: 'italic' }, // % comments
+ '.cm-bracket': { color: '#4A6FA5' }, // braces {}
+ '.cm-tag': { color: '#8B2252' }, // LaTeX tags
+ '.cm-builtin': { color: '#6B5B3E' }, // builtins
+ '.ͼ5': { color: '#8B2252' }, // keywords like \begin
+ '.ͼ6': { color: '#4A6FA5' }, // braces/brackets
+ '.ͼ7': { color: '#5B8A3C' }, // strings
+ '.ͼ8': { color: '#A09880' }, // comments
+}, { dark: false })
+
+export default function Editor() {
+ const editorRef = useRef<HTMLDivElement>(null)
+ const viewRef = useRef<EditorView | null>(null)
+ const { activeTab, fileContents, openTabs, setFileContent, markModified } = useAppStore()
+
+ const pendingGoTo = useAppStore((s) => s.pendingGoTo)
+ const content = activeTab ? fileContents[activeTab] ?? '' : ''
+
+ // Handle goTo when file is already open (no editor recreation needed)
+ useEffect(() => {
+ if (!pendingGoTo || !viewRef.current) return
+ if (activeTab !== pendingGoTo.file) return
+
+ const view = viewRef.current
+ const lineNum = Math.min(pendingGoTo.line, view.state.doc.lines)
+ const lineInfo = view.state.doc.line(lineNum)
+ view.dispatch({
+ selection: { anchor: lineInfo.from },
+ effects: EditorView.scrollIntoView(lineInfo.from, { y: 'center' })
+ })
+ view.focus()
+ useAppStore.getState().setPendingGoTo(null)
+ }, [pendingGoTo])
+
+ // Create/update editor
+ useEffect(() => {
+ if (!editorRef.current) return
+
+ if (viewRef.current) {
+ viewRef.current.destroy()
+ }
+
+ const updateListener = EditorView.updateListener.of((update) => {
+ if (update.docChanged && activeTab) {
+ const newContent = update.state.doc.toString()
+ setFileContent(activeTab, newContent)
+ markModified(activeTab, true)
+ }
+ })
+
+ const state = EditorState.create({
+ doc: content,
+ extensions: [
+ lineNumbers(),
+ highlightActiveLine(),
+ highlightActiveLineGutter(),
+ drawSelection(),
+ rectangularSelection(),
+ indentOnInput(),
+ bracketMatching(),
+ closeBrackets(),
+ foldGutter(),
+ history(),
+ highlightSelectionMatches(),
+ StreamLanguage.define(stex),
+ keymap.of([
+ ...defaultKeymap,
+ ...historyKeymap,
+ ...closeBracketsKeymap,
+ ...searchKeymap,
+ indentWithTab
+ ]),
+ cosmicLatteTheme,
+ updateListener,
+ EditorView.lineWrapping
+ ]
+ })
+
+ const view = new EditorView({
+ state,
+ parent: editorRef.current
+ })
+ viewRef.current = view
+
+ // Apply pending navigation (from log click)
+ const goTo = useAppStore.getState().pendingGoTo
+ if (goTo && goTo.file === activeTab && goTo.line) {
+ requestAnimationFrame(() => {
+ const lineNum = Math.min(goTo.line, view.state.doc.lines)
+ const lineInfo = view.state.doc.line(lineNum)
+ view.dispatch({
+ selection: { anchor: lineInfo.from },
+ effects: EditorView.scrollIntoView(lineInfo.from, { y: 'center' })
+ })
+ view.focus()
+ useAppStore.getState().setPendingGoTo(null)
+ })
+ }
+
+ return () => {
+ viewRef.current?.destroy()
+ viewRef.current = null
+ }
+ }, [activeTab]) // Re-create when tab changes
+
+ if (!activeTab) {
+ return (
+ <div className="editor-empty">
+ <div className="editor-empty-content">
+ <p>Open a file to start editing</p>
+ <p className="shortcut-hint">
+ Cmd+S Save &middot; Cmd+B Compile &middot; Cmd+` Terminal
+ </p>
+ </div>
+ </div>
+ )
+ }
+
+ return (
+ <div className="editor-panel">
+ <div className="tab-bar">
+ {openTabs.map((tab) => (
+ <div
+ key={tab.path}
+ className={`tab ${tab.path === activeTab ? 'active' : ''}`}
+ onClick={() => useAppStore.getState().setActiveTab(tab.path)}
+ >
+ <span className="tab-name">
+ {tab.modified && <span className="tab-dot">●</span>}
+ {tab.name}
+ </span>
+ <button
+ className="tab-close"
+ onClick={(e) => {
+ e.stopPropagation()
+ useAppStore.getState().closeTab(tab.path)
+ }}
+ >
+ ×
+ </button>
+ </div>
+ ))}
+ </div>
+ <div ref={editorRef} className="editor-content" />
+ </div>
+ )
+}
diff --git a/src/renderer/src/components/FileTree.tsx b/src/renderer/src/components/FileTree.tsx
new file mode 100644
index 0000000..3163485
--- /dev/null
+++ b/src/renderer/src/components/FileTree.tsx
@@ -0,0 +1,165 @@
+import { useState, useCallback } from 'react'
+import { useAppStore } from '../stores/appStore'
+import { showInput, showConfirm } from '../hooks/useModal'
+
+interface FileNode {
+ name: string
+ path: string
+ isDir: boolean
+ children?: FileNode[]
+}
+
+function FileTreeNode({ node, depth }: { node: FileNode; depth: number }) {
+ const [expanded, setExpanded] = useState(depth < 2)
+ const { activeTab, mainDocument, openFile, setFileContent, setStatusMessage } = useAppStore()
+ const isActive = activeTab === node.path
+ const isMainDoc = mainDocument === node.path
+
+ const handleClick = useCallback(async () => {
+ if (node.isDir) {
+ setExpanded(!expanded)
+ return
+ }
+
+ const ext = node.name.split('.').pop()?.toLowerCase()
+ if (ext === 'pdf' || ext === 'png' || ext === 'jpg' || ext === 'jpeg' || ext === 'svg') {
+ if (ext === 'pdf') {
+ useAppStore.getState().setPdfPath(node.path)
+ }
+ return
+ }
+
+ try {
+ const content = await window.api.readFile(node.path)
+ setFileContent(node.path, content)
+ openFile(node.path, node.name)
+ } catch {
+ setStatusMessage('Failed to read file')
+ }
+ }, [node, expanded, openFile, setFileContent, setStatusMessage])
+
+ const ext = node.name.split('.').pop()?.toLowerCase() ?? ''
+ const icon = node.isDir
+ ? expanded ? '📂' : '📁'
+ : ext === 'tex' ? '📄'
+ : ext === 'bib' ? '📚'
+ : ext === 'pdf' ? '📕'
+ : ext === 'png' || ext === 'jpg' ? '🖼️'
+ : '📝'
+
+ const [contextMenu, setContextMenu] = useState<{ x: number; y: number } | null>(null)
+
+ const handleContextMenu = (e: React.MouseEvent) => {
+ e.preventDefault()
+ setContextMenu({ x: e.clientX, y: e.clientY })
+ const handler = () => { setContextMenu(null); window.removeEventListener('click', handler) }
+ window.addEventListener('click', handler)
+ }
+
+ const handleNewFile = async () => {
+ setContextMenu(null)
+ const name = await showInput('New File', 'main.tex')
+ if (!name) return
+ const dir = node.isDir ? node.path : node.path.substring(0, node.path.lastIndexOf('/'))
+ await window.api.createFile(dir, name)
+ }
+
+ const handleNewFolder = async () => {
+ setContextMenu(null)
+ const name = await showInput('New Folder', 'figures')
+ if (!name) return
+ const dir = node.isDir ? node.path : node.path.substring(0, node.path.lastIndexOf('/'))
+ await window.api.createDir(dir, name)
+ }
+
+ const handleRename = async () => {
+ setContextMenu(null)
+ const newName = await showInput('Rename', node.name, node.name)
+ if (!newName || newName === node.name) return
+ const dir = node.path.substring(0, node.path.lastIndexOf('/'))
+ await window.api.renameFile(node.path, dir + '/' + newName)
+ }
+
+ const handleDelete = async () => {
+ setContextMenu(null)
+ const ok = await showConfirm('Delete', `Delete "${node.name}"?`, true)
+ if (!ok) return
+ await window.api.deleteFile(node.path)
+ }
+
+ const handleSetMainDoc = () => {
+ setContextMenu(null)
+ useAppStore.getState().setMainDocument(node.path)
+ setStatusMessage(`Main document: ${node.name}`)
+ }
+
+ const handleReveal = () => {
+ window.api.showInFinder(node.path)
+ setContextMenu(null)
+ }
+
+ return (
+ <div>
+ <div
+ className={`file-tree-item ${isActive ? 'active' : ''}`}
+ style={{ paddingLeft: depth * 16 + 8 }}
+ onClick={handleClick}
+ onContextMenu={handleContextMenu}
+ >
+ <span className="file-icon">{icon}</span>
+ <span className="file-name">{node.name}</span>
+ {isMainDoc && <span className="main-doc-badge">main</span>}
+ </div>
+ {contextMenu && (
+ <div className="context-menu" style={{ left: contextMenu.x, top: contextMenu.y }}>
+ {!node.isDir && ext === 'tex' && (
+ <>
+ <div className="context-menu-item" onClick={handleSetMainDoc}>
+ {isMainDoc ? '✓ Main Document' : 'Set as Main Document'}
+ </div>
+ <div className="context-menu-separator" />
+ </>
+ )}
+ <div className="context-menu-item" onClick={handleNewFile}>New File</div>
+ <div className="context-menu-item" onClick={handleNewFolder}>New Folder</div>
+ <div className="context-menu-separator" />
+ <div className="context-menu-item" onClick={handleRename}>Rename</div>
+ <div className="context-menu-item danger" onClick={handleDelete}>Delete</div>
+ <div className="context-menu-separator" />
+ <div className="context-menu-item" onClick={handleReveal}>Reveal in Finder</div>
+ </div>
+ )}
+ {node.isDir && expanded && node.children?.map((child) => (
+ <FileTreeNode key={child.path} node={child} depth={depth + 1} />
+ ))}
+ </div>
+ )
+}
+
+export default function FileTree() {
+ const { files, projectPath } = useAppStore()
+
+ const handleNewFile = async () => {
+ if (!projectPath) return
+ const name = await showInput('New File', 'main.tex')
+ if (!name) return
+ await window.api.createFile(projectPath, name)
+ }
+
+ return (
+ <div className="file-tree">
+ <div className="file-tree-header">
+ <span>FILES</span>
+ <button className="file-tree-action" onClick={handleNewFile} title="New file">+</button>
+ </div>
+ <div className="file-tree-content">
+ {files.map((node) => (
+ <FileTreeNode key={node.path} node={node} depth={0} />
+ ))}
+ {files.length === 0 && (
+ <div className="file-tree-empty">No files found</div>
+ )}
+ </div>
+ </div>
+ )
+}
diff --git a/src/renderer/src/components/ModalProvider.tsx b/src/renderer/src/components/ModalProvider.tsx
new file mode 100644
index 0000000..75572d5
--- /dev/null
+++ b/src/renderer/src/components/ModalProvider.tsx
@@ -0,0 +1,123 @@
+import { useState, useEffect, useRef } from 'react'
+import { useModalStore } from '../hooks/useModal'
+
+export default function ModalProvider() {
+ return (
+ <>
+ <InputModal />
+ <ConfirmModal />
+ <AlertModal />
+ </>
+ )
+}
+
+function InputModal() {
+ const { inputOpen, inputTitle, inputPlaceholder, inputDefault, inputResolve } = useModalStore()
+ const [value, setValue] = useState('')
+ const inputRef = useRef<HTMLInputElement>(null)
+
+ useEffect(() => {
+ if (inputOpen) {
+ setValue(inputDefault)
+ setTimeout(() => {
+ inputRef.current?.focus()
+ inputRef.current?.select()
+ }, 50)
+ }
+ }, [inputOpen, inputDefault])
+
+ if (!inputOpen) return null
+
+ const close = (result: string | null) => {
+ useModalStore.setState({ inputOpen: false })
+ inputResolve?.(result)
+ }
+
+ return (
+ <div className="modal-overlay" onClick={() => close(null)}>
+ <form
+ className="modal-box"
+ onClick={(e) => e.stopPropagation()}
+ onSubmit={(e) => { e.preventDefault(); if (value.trim()) close(value.trim()) }}
+ >
+ <div className="modal-title">{inputTitle}</div>
+ <input
+ ref={inputRef}
+ className="modal-input"
+ type="text"
+ value={value}
+ onChange={(e) => setValue(e.target.value)}
+ placeholder={inputPlaceholder}
+ onKeyDown={(e) => { if (e.key === 'Escape') close(null) }}
+ />
+ <div className="modal-actions">
+ <button type="button" className="btn btn-secondary" onClick={() => close(null)}>Cancel</button>
+ <button type="submit" className="btn btn-primary" disabled={!value.trim()}>OK</button>
+ </div>
+ </form>
+ </div>
+ )
+}
+
+function ConfirmModal() {
+ const { confirmOpen, confirmTitle, confirmMessage, confirmDanger, confirmResolve } = useModalStore()
+ const btnRef = useRef<HTMLButtonElement>(null)
+
+ useEffect(() => {
+ if (confirmOpen) setTimeout(() => btnRef.current?.focus(), 50)
+ }, [confirmOpen])
+
+ if (!confirmOpen) return null
+
+ const close = (result: boolean) => {
+ useModalStore.setState({ confirmOpen: false })
+ confirmResolve?.(result)
+ }
+
+ return (
+ <div className="modal-overlay" onClick={() => close(false)}>
+ <div className="modal-box" onClick={(e) => e.stopPropagation()}>
+ <div className="modal-title">{confirmTitle}</div>
+ <div className="modal-message">{confirmMessage}</div>
+ <div className="modal-actions">
+ <button className="btn btn-secondary" onClick={() => close(false)}>Cancel</button>
+ <button
+ ref={btnRef}
+ className={`btn ${confirmDanger ? 'btn-danger' : 'btn-primary'}`}
+ onClick={() => close(true)}
+ >
+ {confirmDanger ? 'Delete' : 'Confirm'}
+ </button>
+ </div>
+ </div>
+ </div>
+ )
+}
+
+function AlertModal() {
+ const { alertOpen, alertTitle, alertMessage, alertResolve } = useModalStore()
+ const btnRef = useRef<HTMLButtonElement>(null)
+
+ useEffect(() => {
+ if (alertOpen) setTimeout(() => btnRef.current?.focus(), 50)
+ }, [alertOpen])
+
+ if (!alertOpen) return null
+
+ const close = () => {
+ useModalStore.setState({ alertOpen: false })
+ alertResolve?.()
+ }
+
+ return (
+ <div className="modal-overlay" onClick={close}>
+ <div className="modal-box" onClick={(e) => e.stopPropagation()}>
+ <div className="modal-title">{alertTitle}</div>
+ <div className="modal-message modal-message-mono">{alertMessage}</div>
+ <div className="modal-actions">
+ <button ref={btnRef} className="btn btn-primary" onClick={close}>OK</button>
+ </div>
+ </div>
+ </div>
+ )
+}
diff --git a/src/renderer/src/components/OverleafConnect.tsx b/src/renderer/src/components/OverleafConnect.tsx
new file mode 100644
index 0000000..6258643
--- /dev/null
+++ b/src/renderer/src/components/OverleafConnect.tsx
@@ -0,0 +1,171 @@
+import { useState, useEffect } from 'react'
+import { useAppStore } from '../stores/appStore'
+
+interface Props {
+ onConnected: (projectPath: string) => void
+ onCancel: () => void
+}
+
+export default function OverleafConnect({ onConnected, onCancel }: Props) {
+ const [projectUrl, setProjectUrl] = useState('')
+ const [token, setToken] = useState('')
+ const [hasStoredToken, setHasStoredToken] = useState(false)
+ const [busy, setBusy] = useState(false)
+ const [busyText, setBusyText] = useState('')
+ const [rememberMe, setRememberMe] = useState(true)
+ const [error, setError] = useState('')
+ const { setStatusMessage } = useAppStore()
+
+ // Check if we already have stored credentials
+ useEffect(() => {
+ window.api.overleafCheck().then(({ loggedIn }) => {
+ if (loggedIn) setHasStoredToken(true)
+ })
+ }, [])
+
+ const extractProjectId = (url: string): string | null => {
+ const cleaned = url.trim()
+ if (!cleaned) return null
+ const patterns = [
+ /overleaf\.com\/project\/([a-zA-Z0-9]+)/,
+ /overleaf\.com\/read\/([a-zA-Z0-9]+)/,
+ /git\.overleaf\.com\/([a-zA-Z0-9]+)/,
+ /^([a-zA-Z0-9]{10,})$/,
+ ]
+ for (const p of patterns) {
+ const m = cleaned.match(p)
+ if (m) return m[1]
+ }
+ return null
+ }
+
+ const projectId = extractProjectId(projectUrl)
+
+ const handleClone = async () => {
+ if (!projectUrl.trim()) {
+ setError('Please paste an Overleaf project URL'); return
+ }
+ if (!projectId) {
+ setError('Could not find project ID in this URL.\nExpected: https://www.overleaf.com/project/abc123...'); return
+ }
+ if (!token.trim()) {
+ setError('Please enter your Git Authentication Token'); return
+ }
+
+ setError('')
+ setBusy(true)
+ setBusyText('Choose where to save...')
+ setStatusMessage('Connecting to Overleaf...')
+
+ const parentDir = await window.api.selectSaveDir()
+ if (!parentDir) {
+ setBusy(false)
+ return
+ }
+ const dest = parentDir + '/overleaf-' + projectId
+
+ setBusyText('Verifying token & cloning...')
+
+ const result = await window.api.overleafCloneWithAuth(projectId, dest, token.trim(), rememberMe)
+
+ setBusy(false)
+
+ if (result.success) {
+ setStatusMessage('Cloned successfully')
+ onConnected(dest)
+ } else {
+ setStatusMessage('Clone failed')
+ setError(result.detail || 'Unknown error')
+ }
+ }
+
+ const handleClearToken = async () => {
+ await window.api.overleafLogout()
+ setHasStoredToken(false)
+ setToken('')
+ }
+
+ return (
+ <div className="modal-overlay" onClick={onCancel}>
+ <div className="overleaf-dialog" onClick={(e) => e.stopPropagation()}>
+ <div className="overleaf-header">
+ <h2>Clone from Overleaf</h2>
+ <button className="overleaf-close" onClick={onCancel}>x</button>
+ </div>
+
+ {error && <div className="overleaf-error">{error}</div>}
+
+ {busy ? (
+ <div className="overleaf-body">
+ <div className="overleaf-cloning">
+ <div className="overleaf-spinner" />
+ <div className="overleaf-log">{busyText}</div>
+ </div>
+ </div>
+ ) : (
+ <div className="overleaf-body">
+ {/* Project URL */}
+ <label className="overleaf-label">Project URL</label>
+ <input
+ className="modal-input"
+ type="text"
+ value={projectUrl}
+ onChange={(e) => { setProjectUrl(e.target.value); setError('') }}
+ placeholder="https://www.overleaf.com/project/..."
+ autoFocus
+ />
+ <div className="overleaf-help">
+ Copy from your browser address bar, or from Overleaf Menu &rarr; Sync &rarr; Git.
+ </div>
+ {projectId && (
+ <div className="overleaf-id-preview">
+ Project ID: <code>{projectId}</code>
+ </div>
+ )}
+
+ {/* Token */}
+ <div className="overleaf-section-title" style={{ marginTop: 20 }}>
+ Git Authentication Token
+ {hasStoredToken && (
+ <span className="overleaf-saved-hint">
+ (saved in Keychain — <button className="overleaf-link-btn" onClick={handleClearToken}>clear</button>)
+ </span>
+ )}
+ </div>
+ <input
+ className="modal-input"
+ type="password"
+ value={token}
+ onChange={(e) => setToken(e.target.value)}
+ placeholder="olp_..."
+ onKeyDown={(e) => { if (e.key === 'Enter') handleClone() }}
+ />
+ <label className="overleaf-checkbox">
+ <input
+ type="checkbox"
+ checked={rememberMe}
+ onChange={(e) => setRememberMe(e.target.checked)}
+ />
+ Remember token (saved in macOS Keychain)
+ </label>
+
+ <div className="overleaf-help">
+ Generate at{' '}
+ <span className="overleaf-link" onClick={() => window.api.openExternal('https://www.overleaf.com/user/settings')}>
+ Overleaf Account Settings
+ </span>
+ {' '}&rarr; Git Integration. Requires premium.
+ </div>
+
+ <div className="modal-actions">
+ <button className="btn btn-secondary" onClick={onCancel}>Cancel</button>
+ <button className="btn btn-primary" onClick={handleClone}>
+ Verify & Clone
+ </button>
+ </div>
+ </div>
+ )}
+ </div>
+ </div>
+ )
+}
diff --git a/src/renderer/src/components/PdfViewer.tsx b/src/renderer/src/components/PdfViewer.tsx
new file mode 100644
index 0000000..e702f15
--- /dev/null
+++ b/src/renderer/src/components/PdfViewer.tsx
@@ -0,0 +1,391 @@
+import { useEffect, useRef, useState, useCallback } from 'react'
+import * as pdfjsLib from 'pdfjs-dist'
+import { useAppStore } from '../stores/appStore'
+
+pdfjsLib.GlobalWorkerOptions.workerSrc = new URL(
+ 'pdfjs-dist/build/pdf.worker.mjs',
+ import.meta.url
+).toString()
+
+// ── Log parsing (Overleaf-style) ──────────────────────────────────
+
+interface LogEntry {
+ level: 'error' | 'warning' | 'info'
+ message: string
+ file?: string
+ line?: number
+}
+
+function parseCompileLog(raw: string): LogEntry[] {
+ const entries: LogEntry[] = []
+ const lines = raw.split('\n')
+
+ for (let i = 0; i < lines.length; i++) {
+ const ln = lines[i]
+
+ // LaTeX Error: ...
+ if (/^!/.test(ln) || /LaTeX Error:/.test(ln)) {
+ let msg = ln.replace(/^!\s*/, '')
+ // Collect continuation lines
+ while (i + 1 < lines.length && lines[i + 1] && !lines[i + 1].startsWith('l.') && !lines[i + 1].startsWith('!')) {
+ i++
+ if (lines[i].trim()) msg += ' ' + lines[i].trim()
+ }
+ // Try to get line number from "l.123" line
+ let lineNum: number | undefined
+ if (i + 1 < lines.length && /^l\.(\d+)/.test(lines[i + 1])) {
+ i++
+ lineNum = parseInt(lines[i].match(/^l\.(\d+)/)![1])
+ }
+ entries.push({ level: 'error', message: msg.trim(), line: lineNum })
+ continue
+ }
+
+ // file:line: error pattern (file-line-error mode)
+ const fileLineErr = ln.match(/^\.\/(.+?):(\d+):\s*(.+)/)
+ if (fileLineErr) {
+ const msg = fileLineErr[3]
+ const isWarning = /warning/i.test(msg)
+ entries.push({
+ level: isWarning ? 'warning' : 'error',
+ message: msg,
+ file: fileLineErr[1],
+ line: parseInt(fileLineErr[2])
+ })
+ continue
+ }
+
+ // Package ... Warning:
+ const pkgWarn = ln.match(/Package (\S+) Warning:\s*(.*)/)
+ if (pkgWarn) {
+ let msg = `[${pkgWarn[1]}] ${pkgWarn[2]}`
+ let warnLine: number | undefined
+ // Collect continuation lines starting with (pkgname)
+ while (i + 1 < lines.length && /^\(/.test(lines[i + 1])) {
+ i++
+ const contLine = lines[i]
+ msg += ' ' + contLine.replace(/^\([^)]*\)\s*/, '').trim()
+ const lineMatch = contLine.match(/on input line (\d+)/)
+ if (lineMatch) warnLine = parseInt(lineMatch[1])
+ }
+ // Also check the initial line for "on input line N"
+ if (!warnLine) {
+ const lineMatch = msg.match(/on input line (\d+)/)
+ if (lineMatch) warnLine = parseInt(lineMatch[1])
+ }
+ entries.push({ level: 'warning', message: msg.trim(), line: warnLine })
+ continue
+ }
+
+ // LaTeX Warning:
+ const latexWarn = ln.match(/LaTeX Warning:\s*(.*)/)
+ if (latexWarn) {
+ let msg = latexWarn[1]
+ while (i + 1 < lines.length && lines[i + 1] && !lines[i + 1].match(/^[(!.]/) && lines[i + 1].startsWith(' ')) {
+ i++
+ msg += ' ' + lines[i].trim()
+ }
+ const lineMatch = msg.match(/on input line (\d+)/)
+ entries.push({ level: 'warning', message: msg.trim(), line: lineMatch ? parseInt(lineMatch[1]) : undefined })
+ continue
+ }
+
+ // Overfull / Underfull
+ const overunder = ln.match(/^(Overfull|Underfull) .* at lines (\d+)--(\d+)/)
+ if (overunder) {
+ entries.push({ level: 'warning', message: ln.trim(), line: parseInt(overunder[2]) })
+ continue
+ }
+ if (/^(Overfull|Underfull)/.test(ln)) {
+ const paraMatch = ln.match(/in paragraph at lines (\d+)--(\d+)/)
+ entries.push({ level: 'warning', message: ln.trim(), line: paraMatch ? parseInt(paraMatch[1]) : undefined })
+ continue
+ }
+
+ // Missing file
+ if (/File .* not found/.test(ln)) {
+ entries.push({ level: 'error', message: ln.trim() })
+ continue
+ }
+ }
+
+ // Deduplicate
+ const seen = new Set<string>()
+ return entries.filter((e) => {
+ const key = `${e.level}:${e.message}`
+ if (seen.has(key)) return false
+ seen.add(key)
+ return true
+ })
+}
+
+// ── Component ─────────────────────────────────────────────────────
+
+type LogFilter = 'all' | 'error' | 'warning'
+
+export default function PdfViewer() {
+ const { pdfPath, compileLog, compiling } = useAppStore()
+ const containerRef = useRef<HTMLDivElement>(null)
+ const [scale, setScale] = useState(1.0)
+ const [numPages, setNumPages] = useState(0)
+ const [tab, setTab] = useState<'pdf' | 'log'>('pdf')
+ const [logFilter, setLogFilter] = useState<LogFilter>('all')
+ const [error, setError] = useState<string | null>(null)
+ const prevCompilingRef = useRef(false)
+ const renderingRef = useRef(false)
+
+ // Parse and sort log entries (errors first, then warnings)
+ const logEntries = compileLog ? parseCompileLog(compileLog) : []
+ const levelOrder = { error: 0, warning: 1, info: 2 }
+ logEntries.sort((a, b) => levelOrder[a.level] - levelOrder[b.level])
+
+ const errorCount = logEntries.filter((e) => e.level === 'error').length
+ const warningCount = logEntries.filter((e) => e.level === 'warning').length
+
+ const filteredEntries = logFilter === 'all'
+ ? logEntries
+ : logEntries.filter((e) => e.level === logFilter)
+
+ // Navigate to file:line in editor
+ const handleEntryClick = async (entry: LogEntry) => {
+ if (!entry.line) return
+ const { projectPath, mainDocument } = useAppStore.getState()
+ if (!projectPath) return
+
+ // If no file specified, use the main document
+ const entryFile = entry.file || (mainDocument ? mainDocument.split('/').pop()! : null)
+ if (!entryFile) return
+
+ // Try resolving the file path
+ const candidates = [
+ entryFile.startsWith('/') ? entryFile : `${projectPath}/${entryFile}`,
+ ]
+ if (mainDocument) {
+ const dir = mainDocument.substring(0, mainDocument.lastIndexOf('/'))
+ candidates.push(`${dir}/${entryFile}`)
+ }
+
+ for (const fullPath of candidates) {
+ try {
+ const content = await window.api.readFile(fullPath)
+ useAppStore.getState().setFileContent(fullPath, content)
+ useAppStore.getState().openFile(fullPath, fullPath.split('/').pop() || entryFile)
+ useAppStore.getState().setPendingGoTo({ file: fullPath, line: entry.line! })
+ return
+ } catch { /* try next */ }
+ }
+ }
+
+ // Auto-switch tab after compilation finishes
+ useEffect(() => {
+ if (prevCompilingRef.current && !compiling) {
+ if (pdfPath) {
+ setTab('pdf')
+ } else if (compileLog) {
+ setTab('log')
+ }
+ }
+ prevCompilingRef.current = compiling
+ }, [compiling, pdfPath, compileLog])
+
+ // Store page viewports for synctex coordinate conversion
+ const pageViewportsRef = useRef<Map<number, { width: number; height: number }>>(new Map())
+
+ // SyncTeX: double-click PDF → jump to source
+ const handlePdfDoubleClick = useCallback(async (e: MouseEvent) => {
+ if (!pdfPath) return
+ const canvas = (e.target as HTMLElement).closest('canvas.pdf-page') as HTMLCanvasElement | null
+ if (!canvas) return
+
+ const container = containerRef.current
+ if (!container) return
+
+ // Determine which page was clicked
+ const canvases = Array.from(container.querySelectorAll('canvas.pdf-page'))
+ const pageIndex = canvases.indexOf(canvas)
+ if (pageIndex < 0) return
+ const pageNum = pageIndex + 1
+
+ // Get click position relative to canvas (in CSS pixels)
+ const rect = canvas.getBoundingClientRect()
+ const clickX = e.clientX - rect.left
+ const clickY = e.clientY - rect.top
+
+ // Convert to PDF points (72 DPI coordinate system, origin bottom-left)
+ const vpInfo = pageViewportsRef.current.get(pageNum)
+ if (!vpInfo) return
+ const pdfX = (clickX / rect.width) * vpInfo.width
+ const pdfY = vpInfo.height - (clickY / rect.height) * vpInfo.height
+
+ const result = await window.api.synctexEdit(pdfPath, pageNum, pdfX, pdfY)
+ if (!result) return
+
+ // Navigate to the source file:line
+ try {
+ const content = await window.api.readFile(result.file)
+ useAppStore.getState().setFileContent(result.file, content)
+ useAppStore.getState().openFile(result.file, result.file.split('/').pop() || result.file)
+ useAppStore.getState().setPendingGoTo({ file: result.file, line: result.line })
+ } catch { /* file not found */ }
+ }, [pdfPath])
+
+ // Render PDF (with lock to prevent double-render)
+ const renderPdf = useCallback(async () => {
+ if (!pdfPath || !containerRef.current || tab !== 'pdf') return
+ if (renderingRef.current) return
+ renderingRef.current = true
+
+ setError(null)
+ try {
+ const arrayBuffer = await window.api.readBinary(pdfPath)
+ const data = new Uint8Array(arrayBuffer)
+ const pdf = await pdfjsLib.getDocument({ data }).promise
+ setNumPages(pdf.numPages)
+
+ const container = containerRef.current
+ if (!container) { renderingRef.current = false; return }
+ container.innerHTML = ''
+ pageViewportsRef.current.clear()
+
+ for (let i = 1; i <= pdf.numPages; i++) {
+ const page = await pdf.getPage(i)
+ const viewport = page.getViewport({ scale })
+
+ const baseViewport = page.getViewport({ scale: 1 })
+ pageViewportsRef.current.set(i, { width: baseViewport.width, height: baseViewport.height })
+
+ const canvas = document.createElement('canvas')
+ canvas.className = 'pdf-page'
+ const context = canvas.getContext('2d')!
+ canvas.width = viewport.width * window.devicePixelRatio
+ canvas.height = viewport.height * window.devicePixelRatio
+ canvas.style.width = `${viewport.width}px`
+ canvas.style.height = `${viewport.height}px`
+ context.scale(window.devicePixelRatio, window.devicePixelRatio)
+ container.appendChild(canvas)
+ await page.render({ canvasContext: context, viewport }).promise
+ }
+ } catch (err) {
+ setError(`Failed to load PDF: ${err}`)
+ } finally {
+ renderingRef.current = false
+ }
+ }, [pdfPath, scale, tab])
+
+ // Attach double-click listener to PDF container
+ useEffect(() => {
+ const container = containerRef.current
+ if (!container) return
+ container.addEventListener('dblclick', handlePdfDoubleClick)
+ return () => container.removeEventListener('dblclick', handlePdfDoubleClick)
+ }, [handlePdfDoubleClick])
+
+ useEffect(() => {
+ renderPdf()
+ }, [renderPdf])
+
+ // Empty state
+ if (!pdfPath && !compileLog) {
+ return (
+ <div className="pdf-empty">
+ <div className="pdf-empty-content">
+ <p>No PDF to display</p>
+ <p className="shortcut-hint">Compile a .tex file with Cmd+B</p>
+ </div>
+ </div>
+ )
+ }
+
+ return (
+ <div className="pdf-panel">
+ <div className="pdf-toolbar">
+ <button
+ className={`pdf-tab ${tab === 'pdf' ? 'active' : ''}`}
+ onClick={() => setTab('pdf')}
+ >
+ PDF {numPages > 0 && `(${numPages}p)`}
+ </button>
+ <button
+ className={`pdf-tab ${tab === 'log' ? 'active' : ''}`}
+ onClick={() => setTab('log')}
+ >
+ Log
+ {errorCount > 0 && <span className="log-badge log-badge-error">{errorCount}</span>}
+ {warningCount > 0 && <span className="log-badge log-badge-warning">{warningCount}</span>}
+ </button>
+ <div className="pdf-toolbar-spacer" />
+ {tab === 'pdf' && (
+ <>
+ <button className="toolbar-btn" onClick={() => setScale((s) => Math.max(0.25, s - 0.25))}>-</button>
+ <span className="pdf-scale">{Math.round(scale * 100)}%</span>
+ <button className="toolbar-btn" onClick={() => setScale((s) => Math.min(3, s + 0.25))}>+</button>
+ <button className="toolbar-btn" onClick={() => setScale(1.0)}>Fit</button>
+ </>
+ )}
+ {tab === 'log' && (
+ <div className="log-filters">
+ <button className={`log-filter-btn ${logFilter === 'all' ? 'active' : ''}`} onClick={() => setLogFilter('all')}>
+ All ({logEntries.length})
+ </button>
+ <button className={`log-filter-btn ${logFilter === 'error' ? 'active' : ''}`} onClick={() => setLogFilter('error')}>
+ Errors ({errorCount})
+ </button>
+ <button className={`log-filter-btn ${logFilter === 'warning' ? 'active' : ''}`} onClick={() => setLogFilter('warning')}>
+ Warnings ({warningCount})
+ </button>
+ </div>
+ )}
+ </div>
+
+ {/* PDF view — always mounted, hidden when log is shown */}
+ <div className="pdf-container" ref={containerRef} style={{ display: tab === 'pdf' ? undefined : 'none' }}>
+ {error && <div className="pdf-error">{error}</div>}
+ </div>
+
+ {/* Log view */}
+ {tab === 'log' && (
+ <div className="compile-log">
+ {filteredEntries.length > 0 ? (
+ <div className="log-entries">
+ {filteredEntries.map((entry, i) => (
+ <div
+ key={i}
+ className={`log-entry log-entry-${entry.level} ${entry.line ? 'log-entry-clickable' : ''}`}
+ onClick={() => handleEntryClick(entry)}
+ >
+ <div className="log-entry-header">
+ <span className={`log-level-badge level-${entry.level}`}>
+ {entry.level === 'error' ? 'Error' : entry.level === 'warning' ? 'Warning' : 'Info'}
+ </span>
+ {entry.file && (
+ <span className="log-entry-file">
+ {entry.file}{entry.line ? `:${entry.line}` : ''}
+ </span>
+ )}
+ </div>
+ <div className="log-entry-message">{entry.message}</div>
+ </div>
+ ))}
+ </div>
+ ) : compileLog ? (
+ <div className="log-entries">
+ <div className="log-entry log-entry-info">
+ <div className="log-entry-header">
+ <span className="log-level-badge level-info">Info</span>
+ </div>
+ <div className="log-entry-message">No errors or warnings found.</div>
+ </div>
+ </div>
+ ) : (
+ <div className="log-empty">No compile log yet.</div>
+ )}
+ {/* Raw log toggle */}
+ <details className="log-raw">
+ <summary>Raw log output</summary>
+ <pre>{compileLog}</pre>
+ </details>
+ </div>
+ )}
+ </div>
+ )
+}
diff --git a/src/renderer/src/components/StatusBar.tsx b/src/renderer/src/components/StatusBar.tsx
new file mode 100644
index 0000000..cd11bdd
--- /dev/null
+++ b/src/renderer/src/components/StatusBar.tsx
@@ -0,0 +1,26 @@
+import { useAppStore } from '../stores/appStore'
+
+export default function StatusBar() {
+ const { statusMessage, isGitRepo, gitStatus, activeTab, compiling } = useAppStore()
+
+ const lineInfo = activeTab ? activeTab.split('/').pop() : ''
+
+ return (
+ <div className="status-bar">
+ <div className="status-left">
+ {compiling && <span className="status-compiling">Compiling</span>}
+ <span className="status-message">{statusMessage}</span>
+ </div>
+ <div className="status-right">
+ {isGitRepo && (
+ <span className="status-git">
+ Git{gitStatus ? ` (${gitStatus.split('\n').filter(Boolean).length} changes)` : ' (clean)'}
+ </span>
+ )}
+ {lineInfo && <span className="status-file">{lineInfo}</span>}
+ <span className="status-encoding">UTF-8</span>
+ <span className="status-lang">LaTeX</span>
+ </div>
+ </div>
+ )
+}
diff --git a/src/renderer/src/components/Terminal.tsx b/src/renderer/src/components/Terminal.tsx
new file mode 100644
index 0000000..f7e306e
--- /dev/null
+++ b/src/renderer/src/components/Terminal.tsx
@@ -0,0 +1,165 @@
+import { useEffect, useRef, useState } from 'react'
+import { Terminal as XTerm } from '@xterm/xterm'
+import { FitAddon } from '@xterm/addon-fit'
+import '@xterm/xterm/css/xterm.css'
+import { useAppStore } from '../stores/appStore'
+
+export default function Terminal() {
+ const termRef = useRef<HTMLDivElement>(null)
+ const xtermRef = useRef<XTerm | null>(null)
+ const fitAddonRef = useRef<FitAddon | null>(null)
+ const { projectPath } = useAppStore()
+ const [mode, setMode] = useState<'terminal' | 'claude'>('terminal')
+
+ useEffect(() => {
+ if (!termRef.current || !projectPath) return
+
+ const xterm = new XTerm({
+ theme: {
+ background: '#2D2A24',
+ foreground: '#E8DFC0',
+ cursor: '#FFF8E7',
+ selectionBackground: '#5C5040',
+ black: '#2D2A24',
+ red: '#C75643',
+ green: '#5B8A3C',
+ yellow: '#B8860B',
+ blue: '#4A6FA5',
+ magenta: '#8B6B8B',
+ cyan: '#5B8A8A',
+ white: '#E8DFC0',
+ brightBlack: '#6B5B3E',
+ brightRed: '#D46A58',
+ brightGreen: '#6FA050',
+ brightYellow: '#D4A020',
+ brightBlue: '#5E84B8',
+ brightMagenta: '#A080A0',
+ brightCyan: '#6FA0A0',
+ brightWhite: '#FFF8E7'
+ },
+ fontFamily: '"SF Mono", "Fira Code", "JetBrains Mono", monospace',
+ fontSize: 13,
+ cursorBlink: true,
+ scrollback: 10000
+ })
+
+ const fitAddon = new FitAddon()
+ xterm.loadAddon(fitAddon)
+ xterm.open(termRef.current)
+
+ // Fit after a small delay to ensure container is sized
+ setTimeout(() => fitAddon.fit(), 100)
+
+ xtermRef.current = xterm
+ fitAddonRef.current = fitAddon
+
+ // Spawn shell
+ window.api.ptySpawn(projectPath)
+
+ // Pipe data
+ const unsubData = window.api.onPtyData((data) => {
+ xterm.write(data)
+ })
+
+ const unsubExit = window.api.onPtyExit(() => {
+ xterm.writeln('\r\n[Process exited]')
+ })
+
+ // Send input
+ xterm.onData((data) => {
+ window.api.ptyWrite(data)
+ })
+
+ // Handle resize
+ const resizeObserver = new ResizeObserver(() => {
+ try {
+ fitAddon.fit()
+ window.api.ptyResize(xterm.cols, xterm.rows)
+ } catch { /* ignore */ }
+ })
+ resizeObserver.observe(termRef.current)
+
+ return () => {
+ resizeObserver.disconnect()
+ unsubData()
+ unsubExit()
+ window.api.ptyKill()
+ xterm.dispose()
+ }
+ }, [projectPath])
+
+ const launchClaude = () => {
+ if (!xtermRef.current) return
+ window.api.ptyWrite('claude\n')
+ setMode('claude')
+ }
+
+ const sendToClaude = (prompt: string) => {
+ if (!xtermRef.current) return
+ window.api.ptyWrite(prompt + '\n')
+ }
+
+ return (
+ <div className="terminal-panel">
+ <div className="terminal-toolbar">
+ <button
+ className={`pdf-tab ${mode === 'terminal' ? 'active' : ''}`}
+ onClick={() => setMode('terminal')}
+ >
+ Terminal
+ </button>
+ <button
+ className={`pdf-tab ${mode === 'claude' ? 'active' : ''}`}
+ onClick={launchClaude}
+ >
+ Claude
+ </button>
+ <div className="pdf-toolbar-spacer" />
+ <QuickActions onSend={sendToClaude} />
+ </div>
+ <div ref={termRef} className="terminal-content" />
+ </div>
+ )
+}
+
+function QuickActions({ onSend }: { onSend: (cmd: string) => void }) {
+ const { activeTab, fileContents } = useAppStore()
+
+ const actions = [
+ {
+ label: 'Fix Errors',
+ action: () => {
+ const log = useAppStore.getState().compileLog
+ if (log) {
+ onSend(`Fix these LaTeX compilation errors:\n${log.slice(-2000)}`)
+ }
+ }
+ },
+ {
+ label: 'Review',
+ action: () => {
+ if (activeTab && fileContents[activeTab]) {
+ onSend(`Review this LaTeX file for issues and improvements: ${activeTab}`)
+ }
+ }
+ },
+ {
+ label: 'Explain',
+ action: () => {
+ if (activeTab) {
+ onSend(`Explain the structure and content of: ${activeTab}`)
+ }
+ }
+ }
+ ]
+
+ return (
+ <div className="quick-actions">
+ {actions.map((a) => (
+ <button key={a.label} className="toolbar-btn quick-action-btn" onClick={a.action}>
+ {a.label}
+ </button>
+ ))}
+ </div>
+ )
+}
diff --git a/src/renderer/src/components/Toolbar.tsx b/src/renderer/src/components/Toolbar.tsx
new file mode 100644
index 0000000..ac875bd
--- /dev/null
+++ b/src/renderer/src/components/Toolbar.tsx
@@ -0,0 +1,75 @@
+import { useAppStore } from '../stores/appStore'
+
+interface ToolbarProps {
+ onCompile: () => void
+ onSave: () => void
+ onOpenProject: () => void
+}
+
+export default function Toolbar({ onCompile, onSave, onOpenProject }: ToolbarProps) {
+ const { projectPath, compiling, toggleTerminal, toggleFileTree, showTerminal, showFileTree, isGitRepo, mainDocument } = useAppStore()
+ const projectName = projectPath?.split('/').pop() ?? 'ClaudeTeX'
+
+ const handlePull = async () => {
+ if (!projectPath) return
+ useAppStore.getState().setStatusMessage('Pulling from Overleaf...')
+ const result = await window.api.gitPull(projectPath)
+ useAppStore.getState().setStatusMessage(result.success ? 'Pull complete' : 'Pull failed')
+ }
+
+ const handlePush = async () => {
+ if (!projectPath) return
+ useAppStore.getState().setStatusMessage('Pushing to Overleaf...')
+ const result = await window.api.gitPush(projectPath)
+ useAppStore.getState().setStatusMessage(result.success ? 'Push complete' : 'Push failed')
+ }
+
+ return (
+ <div className="toolbar">
+ <div className="toolbar-left">
+ <div className="drag-region" />
+ <button className="toolbar-btn" onClick={toggleFileTree} title="Toggle file tree (Cmd+\\)">
+ {showFileTree ? '◧' : '☰'}
+ </button>
+ <span className="project-name">{projectName}</span>
+ </div>
+ <div className="toolbar-center">
+ <button className="toolbar-btn" onClick={onOpenProject} title="Open project">
+ Open
+ </button>
+ <button className="toolbar-btn" onClick={onSave} title="Save (Cmd+S)">
+ Save
+ </button>
+ <button
+ className={`toolbar-btn toolbar-btn-primary ${compiling ? 'compiling' : ''}`}
+ onClick={onCompile}
+ disabled={compiling}
+ title={`Compile (Cmd+B)${mainDocument ? ' — ' + mainDocument.split('/').pop() : ''}`}
+ >
+ {compiling ? 'Compiling...' : 'Compile'}
+ </button>
+ {mainDocument && (
+ <span className="toolbar-main-doc" title={mainDocument}>
+ {mainDocument.split('/').pop()}
+ </span>
+ )}
+ {isGitRepo && (
+ <>
+ <div className="toolbar-separator" />
+ <button className="toolbar-btn" onClick={handlePull} title="Pull from Overleaf">
+ Pull
+ </button>
+ <button className="toolbar-btn" onClick={handlePush} title="Push to Overleaf">
+ Push
+ </button>
+ </>
+ )}
+ </div>
+ <div className="toolbar-right">
+ <button className="toolbar-btn" onClick={toggleTerminal} title="Toggle terminal (Cmd+`)">
+ {showTerminal ? 'Hide Terminal' : 'Terminal'}
+ </button>
+ </div>
+ </div>
+ )
+}
diff --git a/src/renderer/src/hooks/useModal.ts b/src/renderer/src/hooks/useModal.ts
new file mode 100644
index 0000000..fdf6eb3
--- /dev/null
+++ b/src/renderer/src/hooks/useModal.ts
@@ -0,0 +1,77 @@
+import { create } from 'zustand'
+
+interface ModalState {
+ // Input modal
+ inputOpen: boolean
+ inputTitle: string
+ inputPlaceholder: string
+ inputDefault: string
+ inputResolve: ((value: string | null) => void) | null
+
+ // Confirm modal
+ confirmOpen: boolean
+ confirmTitle: string
+ confirmMessage: string
+ confirmDanger: boolean
+ confirmResolve: ((ok: boolean) => void) | null
+
+ // Alert modal
+ alertOpen: boolean
+ alertTitle: string
+ alertMessage: string
+ alertResolve: (() => void) | null
+}
+
+export const useModalStore = create<ModalState>(() => ({
+ inputOpen: false,
+ inputTitle: '',
+ inputPlaceholder: '',
+ inputDefault: '',
+ inputResolve: null,
+
+ confirmOpen: false,
+ confirmTitle: '',
+ confirmMessage: '',
+ confirmDanger: false,
+ confirmResolve: null,
+
+ alertOpen: false,
+ alertTitle: '',
+ alertMessage: '',
+ alertResolve: null,
+}))
+
+export function showInput(title: string, placeholder = '', defaultValue = ''): Promise<string | null> {
+ return new Promise((resolve) => {
+ useModalStore.setState({
+ inputOpen: true,
+ inputTitle: title,
+ inputPlaceholder: placeholder,
+ inputDefault: defaultValue,
+ inputResolve: resolve,
+ })
+ })
+}
+
+export function showConfirm(title: string, message: string, danger = false): Promise<boolean> {
+ return new Promise((resolve) => {
+ useModalStore.setState({
+ confirmOpen: true,
+ confirmTitle: title,
+ confirmMessage: message,
+ confirmDanger: danger,
+ confirmResolve: resolve,
+ })
+ })
+}
+
+export function showAlert(title: string, message: string): Promise<void> {
+ return new Promise((resolve) => {
+ useModalStore.setState({
+ alertOpen: true,
+ alertTitle: title,
+ alertMessage: message,
+ alertResolve: resolve,
+ })
+ })
+}
diff --git a/src/renderer/src/main.tsx b/src/renderer/src/main.tsx
new file mode 100644
index 0000000..136bef2
--- /dev/null
+++ b/src/renderer/src/main.tsx
@@ -0,0 +1,10 @@
+import React from 'react'
+import ReactDOM from 'react-dom/client'
+import App from './App'
+import './App.css'
+
+ReactDOM.createRoot(document.getElementById('root')!).render(
+ <React.StrictMode>
+ <App />
+ </React.StrictMode>
+)
diff --git a/src/renderer/src/stores/appStore.ts b/src/renderer/src/stores/appStore.ts
new file mode 100644
index 0000000..6476239
--- /dev/null
+++ b/src/renderer/src/stores/appStore.ts
@@ -0,0 +1,139 @@
+import { create } from 'zustand'
+
+interface FileNode {
+ name: string
+ path: string
+ isDir: boolean
+ children?: FileNode[]
+}
+
+interface OpenTab {
+ path: string
+ name: string
+ modified: boolean
+}
+
+interface AppState {
+ // Project
+ projectPath: string | null
+ setProjectPath: (p: string | null) => void
+
+ // File tree
+ files: FileNode[]
+ setFiles: (f: FileNode[]) => void
+
+ // Editor tabs
+ openTabs: OpenTab[]
+ activeTab: string | null
+ openFile: (path: string, name: string) => void
+ closeTab: (path: string) => void
+ setActiveTab: (path: string) => void
+ markModified: (path: string, modified: boolean) => void
+
+ // Editor content cache
+ fileContents: Record<string, string>
+ setFileContent: (path: string, content: string) => void
+
+ // Main document
+ mainDocument: string | null
+ setMainDocument: (p: string | null) => void
+
+ // PDF
+ pdfPath: string | null
+ setPdfPath: (p: string | null) => void
+
+ // Compile
+ compiling: boolean
+ setCompiling: (c: boolean) => void
+ compileLog: string
+ appendCompileLog: (log: string) => void
+ clearCompileLog: () => void
+
+ // Panels
+ showTerminal: boolean
+ toggleTerminal: () => void
+ showFileTree: boolean
+ toggleFileTree: () => void
+
+ // Git/Overleaf
+ isGitRepo: boolean
+ setIsGitRepo: (v: boolean) => void
+ gitStatus: string
+ setGitStatus: (s: string) => void
+
+ // Navigation (from log click → editor)
+ pendingGoTo: { file: string; line: number } | null
+ setPendingGoTo: (g: { file: string; line: number } | null) => void
+
+ // Status
+ statusMessage: string
+ setStatusMessage: (m: string) => void
+}
+
+export const useAppStore = create<AppState>((set) => ({
+ projectPath: null,
+ setProjectPath: (p) => set({ projectPath: p }),
+
+ files: [],
+ setFiles: (f) => set({ files: f }),
+
+ openTabs: [],
+ activeTab: null,
+ openFile: (path, name) =>
+ set((s) => {
+ const exists = s.openTabs.find((t) => t.path === path)
+ if (exists) return { activeTab: path }
+ return {
+ openTabs: [...s.openTabs, { path, name, modified: false }],
+ activeTab: path
+ }
+ }),
+ closeTab: (path) =>
+ set((s) => {
+ const tabs = s.openTabs.filter((t) => t.path !== path)
+ const newContents = { ...s.fileContents }
+ delete newContents[path]
+ return {
+ openTabs: tabs,
+ activeTab: s.activeTab === path ? (tabs[tabs.length - 1]?.path ?? null) : s.activeTab,
+ fileContents: newContents
+ }
+ }),
+ setActiveTab: (path) => set({ activeTab: path }),
+ markModified: (path, modified) =>
+ set((s) => ({
+ openTabs: s.openTabs.map((t) => (t.path === path ? { ...t, modified } : t))
+ })),
+
+ fileContents: {},
+ setFileContent: (path, content) =>
+ set((s) => ({ fileContents: { ...s.fileContents, [path]: content } })),
+
+ mainDocument: null,
+ setMainDocument: (p) => set({ mainDocument: p }),
+
+ pdfPath: null,
+ setPdfPath: (p) => set({ pdfPath: p }),
+
+ compiling: false,
+ setCompiling: (c) => set({ compiling: c }),
+ compileLog: '',
+ appendCompileLog: (log) => set((s) => ({ compileLog: s.compileLog + log })),
+ clearCompileLog: () => set({ compileLog: '' }),
+
+ showTerminal: true,
+ toggleTerminal: () => set((s) => ({ showTerminal: !s.showTerminal })),
+ showFileTree: true,
+ toggleFileTree: () => set((s) => ({ showFileTree: !s.showFileTree })),
+
+ isGitRepo: false,
+ setIsGitRepo: (v) => set({ isGitRepo: v }),
+ gitStatus: '',
+ setGitStatus: (s) => set({ gitStatus: s }),
+
+ pendingGoTo: null,
+ setPendingGoTo: (g) => set({ pendingGoTo: g }),
+
+ statusMessage: 'Ready',
+ setStatusMessage: (m) => set({ statusMessage: m })
+}))