Skip to content

Commit 32db510

Browse files
committed
Add SSH remote markdown file preview support
Shell out to the system ssh binary to open, watch, and tab-complete markdown files on remote hosts (user@host:/path format). Inherits ~/.ssh/config, keys, and agent with zero remote setup. Also refactors: extract shared get_file_mtime helper, move git helpers to git.rs, extract render_local_markdown helper. Adds toggle-visibility eye icons on password fields in settings dialog.
1 parent 641e31b commit 32db510

File tree

15 files changed

+1069
-196
lines changed

15 files changed

+1069
-196
lines changed

crates/markdown_preview_core/js/path-input.js

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -13,9 +13,12 @@
1313
currentGitRoot: null // Git root of current file
1414
};
1515

16-
// Fetch path completions from backend
16+
// Fetch path completions from backend (local or SSH)
1717
async function fetchCompletions(partial) {
1818
try {
19+
if (typeof isSshPath === 'function' && isSshPath(partial)) {
20+
return await invoke('complete_ssh_path', { partial });
21+
}
1922
return await invoke('complete_path', { partial });
2023
} catch (e) {
2124
console.error('Failed to fetch completions:', e);
@@ -186,12 +189,12 @@
186189
modal.innerHTML = `
187190
<div class="path-input-container">
188191
<div class="path-input-header">
189-
<label>Open file or URL</label>
190-
<span class="path-input-hint">Type path to autocomplete, or paste a GitHub URL</span>
192+
<label>Open file, URL, or SSH path</label>
193+
<span class="path-input-hint">Type path, paste GitHub URL, or enter user@host:/path</span>
191194
</div>
192195
<div class="path-input-wrapper">
193196
<input type="text" id="path-input-field" class="path-input-field"
194-
placeholder="/path/to/file.md or https://github.com/..." autocomplete="off" spellcheck="false">
197+
placeholder="/path/to/file.md, https://..., or user@host:/path" autocomplete="off" spellcheck="false">
195198
<div id="path-autocomplete" class="path-autocomplete"></div>
196199
</div>
197200
<div class="path-input-footer">

crates/markdown_preview_core/js/settings-dialog.js

Lines changed: 58 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,13 @@
1818
<h4>GitHub</h4>
1919
<div class="settings-field">
2020
<label for="settings-github-token">Personal Access Token</label>
21-
<input type="password" id="settings-github-token" placeholder="ghp_xxxxxxxxxxxxxxxxxxxx" autocomplete="off" spellcheck="false">
21+
<div class="settings-password-wrapper">
22+
<input type="password" id="settings-github-token" placeholder="ghp_xxxxxxxxxxxxxxxxxxxx" autocomplete="off" spellcheck="false">
23+
<button type="button" class="settings-toggle-vis" data-target="settings-github-token" title="Toggle visibility">
24+
<svg class="eye-icon" viewBox="0 0 16 16" width="16" height="16"><path d="M8 3C4.5 3 1.6 5.1.3 8c1.3 2.9 4.2 5 7.7 5s6.4-2.1 7.7-5C14.4 5.1 11.5 3 8 3zm0 8.3a3.3 3.3 0 1 1 0-6.6 3.3 3.3 0 0 1 0 6.6zm0-5.3a2 2 0 1 0 0 4 2 2 0 0 0 0-4z" fill="currentColor"/></svg>
25+
<svg class="eye-off-icon" viewBox="0 0 16 16" width="16" height="16" style="display:none"><path d="M14.5 1.5l-13 13m3.1-4.9A3.3 3.3 0 0 1 8 4.7m3.4 1.9A3.3 3.3 0 0 1 8 11.3M.3 8c1.3-2.9 4.2-5 7.7-5 1.2 0 2.3.3 3.3.7m2.4 1.7c1 1 1.7 2 2 2.6-1.3 2.9-4.2 5-7.7 5-1.2 0-2.3-.3-3.3-.7" fill="none" stroke="currentColor" stroke-width="1.3" stroke-linecap="round"/></svg>
26+
</button>
27+
</div>
2228
<p class="settings-hint">Used for accessing private repositories. Falls back to GITHUB_TOKEN env var.</p>
2329
</div>
2430
</div>
@@ -39,7 +45,13 @@
3945
</div>
4046
<div class="settings-field" id="settings-api-key-field" style="display:none;">
4147
<label for="settings-ai-api-key">API Key</label>
42-
<input type="password" id="settings-ai-api-key" placeholder="sk-..." autocomplete="off" spellcheck="false">
48+
<div class="settings-password-wrapper">
49+
<input type="password" id="settings-ai-api-key" placeholder="sk-..." autocomplete="off" spellcheck="false">
50+
<button type="button" class="settings-toggle-vis" data-target="settings-ai-api-key" title="Toggle visibility">
51+
<svg class="eye-icon" viewBox="0 0 16 16" width="16" height="16"><path d="M8 3C4.5 3 1.6 5.1.3 8c1.3 2.9 4.2 5 7.7 5s6.4-2.1 7.7-5C14.4 5.1 11.5 3 8 3zm0 8.3a3.3 3.3 0 1 1 0-6.6 3.3 3.3 0 0 1 0 6.6zm0-5.3a2 2 0 1 0 0 4 2 2 0 0 0 0-4z" fill="currentColor"/></svg>
52+
<svg class="eye-off-icon" viewBox="0 0 16 16" width="16" height="16" style="display:none"><path d="M14.5 1.5l-13 13m3.1-4.9A3.3 3.3 0 0 1 8 4.7m3.4 1.9A3.3 3.3 0 0 1 8 11.3M.3 8c1.3-2.9 4.2-5 7.7-5 1.2 0 2.3.3 3.3.7m2.4 1.7c1 1 1.7 2 2 2.6-1.3 2.9-4.2 5-7.7 5-1.2 0-2.3-.3-3.3-.7" fill="none" stroke="currentColor" stroke-width="1.3" stroke-linecap="round"/></svg>
53+
</button>
54+
</div>
4355
<p class="settings-hint" id="settings-api-key-hint"></p>
4456
</div>
4557
<div class="settings-field" id="settings-ollama-url-field" style="display:none;">
@@ -162,6 +174,30 @@
162174
color: #9ca3af;
163175
margin: 4px 0 0 0;
164176
}
177+
.settings-password-wrapper {
178+
position: relative;
179+
display: flex;
180+
align-items: center;
181+
}
182+
.settings-password-wrapper input {
183+
padding-right: 36px;
184+
}
185+
.settings-toggle-vis {
186+
position: absolute;
187+
right: 6px;
188+
background: none;
189+
border: none;
190+
cursor: pointer;
191+
padding: 4px;
192+
color: #9ca3af;
193+
display: flex;
194+
align-items: center;
195+
border-radius: 4px;
196+
}
197+
.settings-toggle-vis:hover {
198+
color: #6b7280;
199+
background: rgba(0,0,0,0.05);
200+
}
165201
.settings-footer {
166202
padding: 12px 20px;
167203
background: #f6f8fa;
@@ -204,6 +240,8 @@
204240
.settings-field input,
205241
.settings-field select { background: #2d2d2d; border-color: #444; color: #fff; }
206242
.settings-hint { color: #6b7280; }
243+
.settings-toggle-vis { color: #6b7280; }
244+
.settings-toggle-vis:hover { color: #9ca3af; background: rgba(255,255,255,0.08); }
207245
.settings-footer { background: #252525; border-color: #333; }
208246
.settings-footer .btn-cancel { background: #333; border-color: #444; color: #fff; }
209247
}
@@ -236,6 +274,24 @@
236274

237275
providerSelect.addEventListener('change', updateFieldVisibility);
238276

277+
// Toggle password visibility
278+
dialog.querySelectorAll('.settings-toggle-vis').forEach(btn => {
279+
btn.addEventListener('click', () => {
280+
const input = document.getElementById(btn.dataset.target);
281+
const eyeIcon = btn.querySelector('.eye-icon');
282+
const eyeOffIcon = btn.querySelector('.eye-off-icon');
283+
if (input.type === 'password') {
284+
input.type = 'text';
285+
eyeIcon.style.display = 'none';
286+
eyeOffIcon.style.display = '';
287+
} else {
288+
input.type = 'password';
289+
eyeIcon.style.display = '';
290+
eyeOffIcon.style.display = 'none';
291+
}
292+
});
293+
});
294+
239295
// Dictionary directory management
240296
let dictDirs = [];
241297
const dictDirsContainer = document.getElementById('settings-dict-dirs');

crates/markdown_preview_core/js/tauri-app.js

Lines changed: 51 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -136,8 +136,10 @@ if (typeof window.__TAURI__ !== 'undefined') {
136136
// Add to path history (use canonical path from result)
137137
if (result.file_path) {
138138
addToPathHistory(result.file_path);
139-
// Fetch diff for the file (async, don't block)
140-
fetchFileDiff(result.file_path);
139+
// Fetch diff for the file (async, don't block) — skip for SSH paths
140+
if (!isSshPath(result.file_path)) {
141+
fetchFileDiff(result.file_path);
142+
}
141143
}
142144
return result;
143145
}
@@ -167,6 +169,13 @@ if (typeof window.__TAURI__ !== 'undefined') {
167169
return str.startsWith('http://') || str.startsWith('https://');
168170
}
169171

172+
// Check if a string is an SSH path (SCP-style: [user@]host:/path)
173+
function isSshPath(str) {
174+
if (!str || str.startsWith('http://') || str.startsWith('https://')) return false;
175+
// Match [user@]host:/path — host must start with alphanumeric
176+
return /^(?:[a-zA-Z0-9][a-zA-Z0-9._-]*@)?[a-zA-Z0-9][a-zA-Z0-9._-]*:\//.test(str);
177+
}
178+
170179
// Open a URL
171180
async function openUrl(url) {
172181
try {
@@ -194,6 +203,23 @@ if (typeof window.__TAURI__ !== 'undefined') {
194203
return null;
195204
}
196205

206+
// Open a file via SSH
207+
async function openSshFile(sshPath) {
208+
try {
209+
showToast('Connecting via SSH...');
210+
const result = await invoke('open_ssh_file', { path: sshPath });
211+
if (result && (result.html || result.output)) {
212+
handleFileOpened(result);
213+
addToPathHistory(sshPath);
214+
return result;
215+
}
216+
} catch (e) {
217+
console.error('Failed to open SSH file:', e);
218+
showToast('SSH error: ' + (e.message || e));
219+
}
220+
return null;
221+
}
222+
197223
// Open a URL with a user-provided GitHub token
198224
// Returns { success: true, result } or { success: false, error }
199225
async function openUrlWithToken(url, token) {
@@ -450,10 +476,12 @@ if (typeof window.__TAURI__ !== 'undefined') {
450476
showToast('Loaded from URL');
451477
}
452478

453-
// Open a path or URL (auto-detects)
479+
// Open a path, URL, or SSH path (auto-detects)
454480
async function openPathOrUrl(input) {
455481
if (isUrl(input)) {
456482
return await openUrl(input);
483+
} else if (isSshPath(input)) {
484+
return await openSshFile(input);
457485
} else {
458486
return await openFile(input);
459487
}
@@ -548,9 +576,15 @@ if (typeof window.__TAURI__ !== 'undefined') {
548576
loadRecentFilesFromBackend();
549577

550578
// Start watching the file for changes
551-
invoke('watch_file', { path: result.file_path }).catch(e => {
552-
console.error('Failed to watch file:', e);
553-
});
579+
if (isSshPath(result.file_path)) {
580+
invoke('watch_ssh_file', { path: result.file_path }).catch(e => {
581+
console.error('Failed to watch SSH file:', e);
582+
});
583+
} else {
584+
invoke('watch_file', { path: result.file_path }).catch(e => {
585+
console.error('Failed to watch file:', e);
586+
});
587+
}
554588
}
555589

556590
// Update metadata bar
@@ -655,6 +689,17 @@ if (typeof window.__TAURI__ !== 'undefined') {
655689
await openFile(event.payload);
656690
});
657691

692+
// Listen for SSH watch error/recovery events
693+
listen('ssh-watch-error', (event) => {
694+
console.warn('SSH watcher error:', event.payload);
695+
showToast('SSH connection lost — retrying...');
696+
});
697+
698+
listen('ssh-watch-recovered', () => {
699+
console.log('SSH watcher recovered');
700+
showToast('SSH connection restored');
701+
});
702+
658703
// Listen for AI summary progress events
659704
listen('ai-summary-progress', (event) => {
660705
const { status, filePath, completed, total } = event.payload;

mead/Cargo.lock

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

mead/Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@ reqwest = { version = "0.12", features = ["json", "native-tls"], default-feature
3333
serde = { version = "1.0", features = ["derive"] }
3434
serde_json = "1.0"
3535
similar = "2"
36-
tokio = { version = "1.36", features = ["sync", "fs", "rt-multi-thread", "macros"] }
36+
tokio = { version = "1.36", features = ["sync", "fs", "process", "rt-multi-thread", "macros", "time"] }
3737
tracing = "0.1"
3838
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
3939

mead/src/ai.rs

Lines changed: 4 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -196,9 +196,7 @@ async fn anthropic_request(
196196

197197
// OAuth tokens require the Claude Code identity in the system prompt.
198198
let effective_system = if oauth {
199-
format!(
200-
"You are Claude Code, Anthropic's official CLI for Claude.\n\n{system_prompt}"
201-
)
199+
format!("You are Claude Code, Anthropic's official CLI for Claude.\n\n{system_prompt}")
202200
} else {
203201
system_prompt.to_string()
204202
};
@@ -221,10 +219,7 @@ async fn anthropic_request(
221219
// OAuth tokens use Bearer auth and must present Claude Code identity headers.
222220
request = request
223221
.header("Authorization", format!("Bearer {api_key}"))
224-
.header(
225-
"anthropic-beta",
226-
"claude-code-20250219,oauth-2025-04-20",
227-
)
222+
.header("anthropic-beta", "claude-code-20250219,oauth-2025-04-20")
228223
.header("user-agent", "claude-cli/2.1.2 (external, cli)")
229224
.header("x-app", "cli");
230225
} else {
@@ -242,11 +237,9 @@ async fn anthropic_request(
242237
let status = response.status();
243238
let body = response.text().await.unwrap_or_default();
244239
if oauth && status == reqwest::StatusCode::UNAUTHORIZED {
245-
return Err(
246-
"Anthropic OAuth token expired or invalid. \
240+
return Err("Anthropic OAuth token expired or invalid. \
247241
Run `claude setup-token` to generate a new one and paste it in Settings."
248-
.to_string(),
249-
);
242+
.to_string());
250243
}
251244
return Err(format!("Anthropic returned status {status}: {body}"));
252245
}

mead/src/commands/diff.rs

Lines changed: 1 addition & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -59,12 +59,8 @@ pub async fn get_file_diff(
5959
.map_err(|e| format!("Failed to read file: {e}"))?;
6060

6161
// Get current file modification time
62-
let current_time = tokio::fs::metadata(&path)
62+
let current_time = super::get_file_mtime(std::path::Path::new(&path))
6363
.await
64-
.ok()
65-
.and_then(|m| m.modified().ok())
66-
.and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
67-
.map(|d| d.as_millis() as u64)
6864
.unwrap_or(0);
6965

7066
// Get previous snapshot and compute diff

0 commit comments

Comments
 (0)