feat: IME-aware chat input, message copy button, Git path scanning

- Fix IME composition issue: Enter during Chinese/Japanese/Korean input
  method composition no longer prematurely sends messages (assistant.js)
  Uses e.isComposing + keyCode 229 guard on keydown handler
- Add one-click copy button to chat message bubbles (both chat.js and
  assistant.js), with hover-reveal animation and checkmark feedback
- Add 'copy' icon to SVG icon library (Lucide style)
- Add CSS for msg-copy-btn in chat.css and assistant.css
- Implement scan_git_paths Rust command: scans common Git installation
  locations on Windows/macOS/Linux (Program Files, Scoop, Chocolatey,
  GitHub Desktop, VS Code, MSYS2, Homebrew, Xcode CLT, etc.)
- Register scan_git_paths in lib.rs, tauri-api.js, dev-api.js
- Add scan button + results UI in settings.js Git path section
- Add i18n keys: gitScan, gitScanning, gitScanEmpty, gitScanUse
This commit is contained in:
晴天
2026-04-06 00:14:18 +08:00
parent 42aeb8b077
commit 9ff91f74d8
11 changed files with 273 additions and 8 deletions

View File

@@ -2431,10 +2431,10 @@ function renderMessages() {
? `<img class="ast-msg-img" src="${img.dataUrl}" alt="${escHtml(img.name)}" style="max-width:${Math.min(img.width || 300, 300)}px" loading="lazy"/>`
: `<div class="ast-msg-img-loading" data-db-id="${img.dbId || ''}"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" width="24" height="24"><rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="8.5" cy="8.5" r="1.5"/><polyline points="21 15 16 10 5 21"/></svg><span>${escHtml(img.name || t('assistant.image'))}</span></div>`
).join('')}</div>` : ''
return `<div class="ast-msg ast-msg-user" data-msg-idx="${idx}"><div class="ast-msg-bubble ast-msg-bubble-user">${imagesHtml}${textPart ? escHtml(textPart) : ''}</div></div>`
return `<div class="ast-msg ast-msg-user" data-msg-idx="${idx}"><div class="ast-msg-bubble ast-msg-bubble-user">${imagesHtml}${textPart ? escHtml(textPart) : ''}</div><div class="ast-msg-meta"><button class="msg-copy-btn" title="${t('common.copy')}">${icon('copy', 12)}</button></div></div>`
} else if (m.role === 'assistant') {
const toolHtml = renderToolBlocks(m.toolHistory)
return `<div class="ast-msg ast-msg-ai" data-msg-idx="${idx}">${toolHtml}<div class="ast-msg-bubble ast-msg-bubble-ai">${renderMarkdown(m.content)}</div></div>`
return `<div class="ast-msg ast-msg-ai" data-msg-idx="${idx}">${toolHtml}<div class="ast-msg-bubble ast-msg-bubble-ai">${renderMarkdown(m.content)}</div><div class="ast-msg-meta"><button class="msg-copy-btn" title="${t('common.copy')}">${icon('copy', 12)}</button></div></div>`
}
return ''
}).join('')
@@ -4067,6 +4067,23 @@ export async function render() {
// ── 事件绑定 ──
// 复制按钮(事件委托)
_messagesEl.addEventListener('click', (e) => {
const copyBtn = e.target.closest('.msg-copy-btn')
if (!copyBtn) return
e.stopPropagation()
const msgWrap = copyBtn.closest('.ast-msg')
const bubble = msgWrap?.querySelector('.ast-msg-bubble')
if (bubble) {
const text = bubble.innerText || bubble.textContent || ''
navigator.clipboard.writeText(text.trim()).then(() => {
copyBtn.classList.add('copied')
copyBtn.innerHTML = icon('check', 12)
setTimeout(() => { copyBtn.classList.remove('copied'); copyBtn.innerHTML = icon('copy', 12) }, 1500)
}).catch(() => {})
}
})
// 右键调试菜单(事件委托)
_messagesEl.addEventListener('contextmenu', (e) => {
const msgEl = e.target.closest('[data-msg-idx]')
@@ -4084,9 +4101,9 @@ export async function render() {
}
})
// Enter 发送Shift+Enter 换行
// Enter 发送Shift+Enter 换行IME 选词中不发送
_textarea.addEventListener('keydown', (e) => {
if (e.key === 'Enter' && !e.shiftKey) {
if (e.key === 'Enter' && !e.shiftKey && !e.isComposing && e.keyCode !== 229) {
e.preventDefault()
if (!_textarea.value.trim() && _pendingImages.length === 0) return
sendMessage(_textarea.value)

View File

@@ -394,7 +394,7 @@ function bindEvents(page) {
})
_textarea.addEventListener('keydown', (e) => {
if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); sendMessage() }
if (e.key === 'Enter' && !e.shiftKey && !e.isComposing && e.keyCode !== 229) { e.preventDefault(); sendMessage() }
if (e.key === 'Escape') hideCmdPanel()
})
@@ -536,7 +536,24 @@ function bindEvents(page) {
_autoScrollEnabled = true
scrollToBottom(true)
})
_messagesEl.addEventListener('click', () => hideCmdPanel())
_messagesEl.addEventListener('click', (e) => {
const copyBtn = e.target.closest('.msg-copy-btn')
if (copyBtn) {
e.stopPropagation()
const msgWrap = copyBtn.closest('.msg')
const bubble = msgWrap?.querySelector('.msg-bubble')
if (bubble) {
const text = bubble.innerText || bubble.textContent || ''
navigator.clipboard.writeText(text.trim()).then(() => {
copyBtn.classList.add('copied')
copyBtn.innerHTML = svgIcon('check', 12)
setTimeout(() => { copyBtn.classList.remove('copied'); copyBtn.innerHTML = svgIcon('copy', 12) }, 1500)
}).catch(() => {})
}
return
}
hideCmdPanel()
})
}
async function loadModelOptions(showToast = false) {
@@ -1733,6 +1750,7 @@ function handleChatEvent(payload) {
parts.push(`<span class="meta-sep">·</span><span class="msg-tokens">${tokenStr}</span>`)
}
}
parts.push(`<button class="msg-copy-btn" title="${t('common.copy')}">${svgIcon('copy', 12)}</button>`)
meta.innerHTML = parts.join('')
wrapper.appendChild(meta)
}
@@ -2335,7 +2353,7 @@ function appendUserMessage(text, attachments = [], msgTime) {
const meta = document.createElement('div')
meta.className = 'msg-meta'
meta.innerHTML = `<span class="msg-time">${formatTime(msgTime || new Date())}</span>`
meta.innerHTML = `<span class="msg-time">${formatTime(msgTime || new Date())}</span><button class="msg-copy-btn" title="${t('common.copy')}">${svgIcon('copy', 12)}</button>`
wrap.appendChild(bubble)
wrap.appendChild(meta)
@@ -2362,7 +2380,7 @@ function appendAiMessage(text, msgTime, images, videos, audios, files, tools) {
const meta = document.createElement('div')
meta.className = 'msg-meta'
meta.innerHTML = `<span class="msg-time">${formatTime(msgTime || new Date())}</span>`
meta.innerHTML = `<span class="msg-time">${formatTime(msgTime || new Date())}</span><button class="msg-copy-btn" title="${t('common.copy')}">${svgIcon('copy', 12)}</button>`
wrap.appendChild(bubble)
wrap.appendChild(meta)

View File

@@ -471,6 +471,13 @@ function bindEvents(page) {
case 'reset-git-path':
await handleResetGitPath(page)
break
case 'scan-git-paths':
await handleScanGitPaths(page)
break
case 'use-scanned-git':
page.querySelector('[data-name="git-path"]').value = btn.dataset.gitPath || ''
await handleSaveGitPath(page)
break
case 'bind-cli':
await handleBindCli(page, btn.dataset.path)
break
@@ -587,7 +594,9 @@ async function loadGitPath(page) {
<input class="input" data-name="git-path" value="${escapeHtml(customValue)}" placeholder="${t('settings.gitPathPlaceholder')}" style="flex:1;min-width:200px">
<button class="btn btn-primary btn-sm" data-action="save-git-path">${t('common.save')}</button>
<button class="btn btn-secondary btn-sm" data-action="reset-git-path">${t('settings.resetDefault')}</button>
<button class="btn btn-secondary btn-sm" data-action="scan-git-paths">${t('settings.gitScan')}</button>
</div>
<div id="git-scan-results"></div>
</div>`
} catch (e) {
bar.innerHTML = `<div class="stat-card" style="padding:16px;color:var(--error)">${e}</div>`
@@ -613,6 +622,29 @@ async function handleSaveGitPath(page) {
await loadGitPath(page)
}
async function handleScanGitPaths(page) {
const container = page.querySelector('#git-scan-results')
if (!container) return
container.innerHTML = `<div style="margin-top:10px;font-size:12px;color:var(--text-secondary)">${t('settings.gitScanning')}</div>`
try {
const results = await api.scanGitPaths()
if (!results || results.length === 0) {
container.innerHTML = `<div style="margin-top:10px;font-size:12px;color:var(--text-tertiary)">${t('settings.gitScanEmpty')}</div>`
return
}
container.innerHTML = `<div style="margin-top:10px;display:flex;flex-direction:column;gap:6px">${results.map(r =>
`<div style="display:flex;align-items:center;gap:8px;font-size:12px;padding:6px 8px;background:var(--bg-tertiary);border-radius:var(--radius-sm)">
<span style="flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap" title="${escapeHtml(r.path)}">${escapeHtml(r.path)}</span>
<span style="color:var(--text-tertiary);flex-shrink:0">${escapeHtml(r.version || '')}</span>
<span class="badge" style="font-size:10px;flex-shrink:0">${escapeHtml(r.source)}</span>
<button class="btn btn-primary btn-sm" style="padding:2px 8px;font-size:11px" data-action="use-scanned-git" data-git-path="${escapeHtml(r.path)}">${t('settings.gitScanUse')}</button>
</div>`
).join('')}</div>`
} catch (e) {
container.innerHTML = `<div style="margin-top:10px;font-size:12px;color:var(--error)">${e}</div>`
}
}
async function handleResetGitPath(page) {
const cfg = await api.readPanelConfig()
delete cfg.gitPath