feat: save short cut image to remote by ssh

- add settings for ssh config
- implement save remote function by ssh
This commit is contained in:
2026-06-11 17:10:58 +08:00
parent 3e4d071b4c
commit 050f2b74c3
17 changed files with 1463 additions and 46 deletions
+55 -3
View File
@@ -23,6 +23,7 @@ import {
ConfirmRegion,
CopyRegionImage,
SaveRegionImage,
SaveRegionToRemote,
CancelRegion,
} from '../wailsjs/go/main/App'
@@ -35,6 +36,20 @@ const hotkeyStatus = ref<HotkeyStatus>({ state: 'unknown' })
type Mode = 'settings' | 'overlay'
const mode = ref<Mode>('settings')
// `SettingsTab` is hoisted to the App shell so the sidebar (which lives
// here) and the inner SettingsView (which renders the matching card) can
// share a single source of truth without an event bus.
type SettingsTab = 'general' | 's3' | 'ssh'
const activeTab = ref<SettingsTab>('general')
// Sidebar entries are declarative so adding a destination type later is
// a one-line change. The label values match the user-facing tab names.
const sidebarItems: Array<{ id: SettingsTab; label: string }> = [
{ id: 'general', label: 'General' },
{ id: 's3', label: 'S3-Conf' },
{ id: 'ssh', label: 'SSH-Conf' },
]
interface OverlayPayload {
cssWidth: number
cssHeight: number
@@ -131,6 +146,28 @@ async function onOverlaySave(rect: {
}
}
async function onOverlaySaveRemote(rect: {
rect: {
x: number
y: number
w: number
h: number
}
annotations: Array<{
tool: string
color: string
points: Array<{ x: number; y: number }>
}>
}) {
mode.value = 'settings'
overlayPayload.value = null
try {
await SaveRegionToRemote(rect as any)
} catch {
/* Surfaced via upload:failure */
}
}
async function onOverlayCancel() {
mode.value = 'settings'
overlayPayload.value = null
@@ -191,6 +228,7 @@ onUnmounted(() => {
@confirm="onOverlayConfirm"
@copy="onOverlayCopy"
@save="onOverlaySave"
@save-remote="onOverlaySaveRemote"
@cancel="onOverlayCancel"
/>
@@ -210,10 +248,18 @@ onUnmounted(() => {
<main class="main">
<aside class="sidebar">
<div class="sidebar-item active"><span class="dot" /> Settings</div>
<div
v-for="item in sidebarItems"
:key="item.id"
class="sidebar-item"
:class="{ active: activeTab === item.id }"
@click="activeTab = item.id"
>
<span class="dot" /> {{ item.label }}
</div>
</aside>
<section class="content">
<SettingsView />
<SettingsView :tab="activeTab" />
</section>
</main>
@@ -304,7 +350,10 @@ onUnmounted(() => {
border-radius: 6px;
font-size: 13px;
color: #374151;
cursor: default;
cursor: pointer;
}
.sidebar-item:hover:not(.active) {
background: rgba(0, 0, 0, 0.04);
}
.sidebar-item.active {
background: rgba(59, 130, 246, 0.12);
@@ -341,6 +390,9 @@ onUnmounted(() => {
.sidebar-item {
color: #d1d5db;
}
.sidebar-item:hover:not(.active) {
background: rgba(255, 255, 255, 0.05);
}
.sidebar-item.active {
background: rgba(59, 130, 246, 0.18);
color: #93c5fd;
+20 -3
View File
@@ -7,6 +7,7 @@ import { computed, onMounted, onUnmounted, ref } from 'vue'
import cancelIcon from '../assets/icons/cancel.svg?raw'
import copyIcon from '../assets/icons/copy.svg?raw'
import saveIcon from '../assets/icons/save-local.svg?raw'
import saveRemoteIcon from '../assets/icons/save-remote.svg?raw'
import uploadIcon from '../assets/icons/upload.svg?raw'
interface Props {
@@ -46,6 +47,10 @@ const emit = defineEmits<{
e: 'save',
payload: { rect: Rect; annotations: Annotation[] }
): void
(
e: 'save-remote',
payload: { rect: Rect; annotations: Annotation[] }
): void
(e: 'cancel'): void
}>()
@@ -108,7 +113,7 @@ const sizeLabel = computed(() => {
const rightToolbarPos = computed(() => {
if (!rect.value) return null
return placeToolbar(rect.value, 144, 40, 'right')
return placeToolbar(rect.value, 180, 40, 'right')
})
const leftToolbarPos = computed(() => {
@@ -344,7 +349,11 @@ function onSave() {
emitAction('save')
}
function emitAction(action: 'confirm' | 'copy' | 'save') {
function onSaveRemote() {
emitAction('save-remote')
}
function emitAction(action: 'confirm' | 'copy' | 'save' | 'save-remote') {
if (!rect.value) return
const payload = {
rect: { ...rect.value },
@@ -353,6 +362,7 @@ function emitAction(action: 'confirm' | 'copy' | 'save') {
if (action === 'confirm') emit('confirm', payload)
if (action === 'copy') emit('copy', payload)
if (action === 'save') emit('save', payload)
if (action === 'save-remote') emit('save-remote', payload)
}
function onCancel() {
@@ -585,6 +595,13 @@ onUnmounted(() => {
@click="onSave"
v-html="saveIcon"
/>
<button
class="action-btn"
data-tip="保存远端"
aria-label="保存远端"
@click="onSaveRemote"
v-html="saveRemoteIcon"
/>
<button
class="action-btn primary"
data-tip="上传云端"
@@ -679,7 +696,7 @@ onUnmounted(() => {
width: 190px;
}
.action-toolbar {
width: 144px;
width: 180px;
}
.icon-btn,
+237 -23
View File
@@ -1,22 +1,33 @@
<script setup lang="ts">
/*
* SettingsView — the home screen of SnapGo before the user triggers a
* capture. It exposes:
* 1. Hotkey configuration
* 2. S3-compatible OSS configuration
* 3. Manual capture button (useful when developing or when the global
* hotkey conflicts with another app)
* capture. The active configuration section is selected by the App-shell
* sidebar and passed in via the `tab` prop, so this view only owns the
* card content for the currently selected destination type:
*
* • "general" — the global hotkey / capture parameters.
* • "s3" — S3-compatible object-storage credentials.
* • "ssh" — SSH/SCP destination for the "save remote" button.
*
* State flow: load() pulls config from Go on mount, save() pushes back.
*/
import { onMounted, ref } from 'vue'
import { computed, onMounted, ref } from 'vue'
import {
GetConfig,
SaveConfig,
TestConnection,
TestSSHConnection,
CaptureNow,
} from '../../wailsjs/go/main/App'
// Tab discriminator shared with the App shell. Kept as a string union so
// the parent can pass the value without importing a type from this view.
type TabId = 'general' | 's3' | 'ssh'
const props = defineProps<{
tab: TabId
}>()
interface S3Config {
endpoint: string
region: string
@@ -28,29 +39,74 @@ interface S3Config {
usePathStyle: boolean
}
interface SSHConfig {
host: string
port: number
user: string
password: string
pathPrefix: string
strictHostKey: boolean
knownHostsPath: string
connectTimeoutSecs: number
}
interface AppConfig {
hotkey: string
s3: S3Config
ssh: SSHConfig
}
const config = ref<AppConfig>({
hotkey: 'cmd+shift+a',
s3: {
endpoint: '',
region: 'us-east-1',
bucket: '',
accessKeyId: '',
secretAccessKey: '',
pathPrefix: 'snapgo/',
publicUrlBase: '',
usePathStyle: true,
},
})
// `defaultConfig` keeps the initial render in sync with the Go-side
// DefaultAppConfig so the UI never flashes empty fields before load()
// completes. Centralising the defaults also avoids subtle drift between
// frontend and backend.
function defaultConfig(): AppConfig {
return {
hotkey: 'cmd+shift+a',
s3: {
endpoint: '',
region: 'us-east-1',
bucket: '',
accessKeyId: '',
secretAccessKey: '',
pathPrefix: 'snapgo/',
publicUrlBase: '',
usePathStyle: true,
},
ssh: {
host: '',
port: 22,
user: '',
password: '',
pathPrefix: 'snapgo/',
strictHostKey: false,
knownHostsPath: '',
connectTimeoutSecs: 10,
},
}
}
const config = ref<AppConfig>(defaultConfig())
const saving = ref(false)
const testing = ref(false)
const testingSSH = ref(false)
const message = ref<{ kind: 'ok' | 'err'; text: string } | null>(null)
// The "remote path" in the UI is shown as "~/<pathPrefix>" so the user
// understands that everything is rooted at the remote home directory.
// We strip leading slashes / tilde here defensively in case someone pastes
// a full path.
const sshPathDisplay = computed({
get: () => config.value.ssh.pathPrefix,
set: (raw: string) => {
let cleaned = raw.trim()
cleaned = cleaned.replace(/^~/, '')
cleaned = cleaned.replace(/^\/+/, '')
config.value.ssh.pathPrefix = cleaned
},
})
function flash(kind: 'ok' | 'err', text: string) {
message.value = { kind, text }
window.setTimeout(() => {
@@ -61,7 +117,13 @@ function flash(kind: 'ok' | 'err', text: string) {
async function load() {
const cfg = await GetConfig()
if (cfg) {
config.value = cfg as unknown as AppConfig
// Merge with defaults so a config file written before SSH support
// existed does not leave ssh fields undefined.
const merged = defaultConfig()
Object.assign(merged, cfg)
merged.s3 = { ...merged.s3, ...(cfg as any).s3 }
merged.ssh = { ...merged.ssh, ...(cfg as any).ssh }
config.value = merged
}
}
@@ -89,6 +151,18 @@ async function test() {
}
}
async function testSSH() {
testingSSH.value = true
try {
await TestSSHConnection(config.value.ssh as any)
flash('ok', 'SSH connection OK')
} catch (err: any) {
flash('err', `SSH test failed: ${err?.message ?? err}`)
} finally {
testingSSH.value = false
}
}
async function manualCapture() {
await CaptureNow()
}
@@ -109,7 +183,8 @@ onMounted(load)
<button class="btn primary" @click="manualCapture">Capture now</button>
</header>
<section class="card">
<!-- General tab only the shortcut configuration lives here. -->
<section v-if="props.tab === 'general'" class="card">
<h2>Shortcut</h2>
<label class="field">
<span>Global hotkey</span>
@@ -123,9 +198,15 @@ onMounted(load)
Tokens (case-insensitive, "+"-separated): cmd / ctrl / option / shift +
az or 09. Example: <code>cmd+shift+a</code>.
</p>
<div class="actions">
<button class="btn primary" :disabled="saving" @click="save">
{{ saving ? 'Saving' : 'Save' }}
</button>
</div>
</section>
<section class="card">
<!-- S3-Conf tab unchanged content, moved out of General. -->
<section v-if="props.tab === 's3'" class="card">
<h2>S3-compatible storage</h2>
<div class="grid">
<label class="field">
@@ -181,6 +262,83 @@ onMounted(load)
</div>
</section>
<!-- SSH-Conf tab destination for the save-remote action. -->
<section v-if="props.tab === 'ssh'" class="card">
<h2>SSH / SCP destination</h2>
<div class="grid">
<label class="field">
<span>Host (IP or domain) *</span>
<input v-model="config.ssh.host" placeholder="192.168.1.10" />
</label>
<label class="field">
<span>Port</span>
<input
v-model.number="config.ssh.port"
type="number"
min="1"
max="65535"
placeholder="22"
/>
</label>
<label class="field">
<span>User *</span>
<input v-model="config.ssh.user" placeholder="ubuntu" />
</label>
<label class="field">
<span>Password (optional)</span>
<input v-model="config.ssh.password" type="password" />
</label>
<label class="field full">
<span>Remote path (under home)</span>
<div class="prefixed-input">
<span class="input-prefix">~/</span>
<input
v-model="sshPathDisplay"
placeholder="snapgo/"
spellcheck="false"
/>
</div>
</label>
<label class="field">
<span>Connect timeout (sec)</span>
<input
v-model.number="config.ssh.connectTimeoutSecs"
type="number"
min="1"
max="120"
placeholder="10"
/>
</label>
<label class="field">
<span>Known hosts path</span>
<input
v-model="config.ssh.knownHostsPath"
placeholder="~/.ssh/known_hosts"
:disabled="!config.ssh.strictHostKey"
/>
</label>
<label class="field checkbox">
<input type="checkbox" v-model="config.ssh.strictHostKey" />
<span>
Strict host key checking (recommended for production hosts)
</span>
</label>
</div>
<p v-if="!config.ssh.password" class="hint warn">
Password is empty please make sure password-less SSH is configured on
this machine (e.g. via <code>ssh-copy-id</code> or your <code>ssh-agent</code>).
</p>
<div class="actions">
<button class="btn" :disabled="testingSSH" @click="testSSH">
{{ testingSSH ? 'Testing' : 'Test connection' }}
</button>
<button class="btn primary" :disabled="saving" @click="save">
{{ saving ? 'Saving' : 'Save' }}
</button>
</div>
</section>
<transition name="fade">
<div
v-if="message"
@@ -204,7 +362,7 @@ onMounted(load)
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 24px;
margin-bottom: 16px;
}
.hero h1 {
margin: 0 0 6px;
@@ -224,6 +382,9 @@ kbd {
padding: 1px 6px;
font-size: 12px;
}
/* Tab strip removed — sidebar in App.vue now drives section selection. */
.card {
background: #ffffff;
border: 1px solid #e5e7eb;
@@ -258,6 +419,7 @@ kbd {
font-weight: 500;
}
.field input[type='text'],
.field input[type='number'],
.field input:not([type='checkbox']) {
border: 1px solid #d1d5db;
border-radius: 6px;
@@ -267,6 +429,8 @@ kbd {
color: inherit;
outline: none;
transition: border-color 0.15s, box-shadow 0.15s;
width: 100%;
box-sizing: border-box;
}
.field input:focus {
border-color: #3b82f6;
@@ -278,11 +442,48 @@ kbd {
gap: 8px;
grid-column: 1 / -1;
}
.field input:disabled {
background: #f3f4f6;
color: #9ca3af;
cursor: not-allowed;
}
.prefixed-input {
display: flex;
align-items: stretch;
border: 1px solid #d1d5db;
border-radius: 6px;
background: #fff;
overflow: hidden;
}
.prefixed-input:focus-within {
border-color: #3b82f6;
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.18);
}
.input-prefix {
padding: 7px 10px;
background: #f3f4f6;
color: #4b5563;
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
font-size: 13px;
border-right: 1px solid #d1d5db;
}
.prefixed-input input {
border: 0 !important;
border-radius: 0 !important;
flex: 1;
padding: 7px 10px;
}
.prefixed-input input:focus {
box-shadow: none !important;
}
.hint {
margin: 8px 0 0;
font-size: 12px;
color: #6b7280;
}
.hint.warn {
color: #b45309;
}
.hint code {
background: #f3f4f6;
padding: 1px 6px;
@@ -359,6 +560,19 @@ kbd {
border-color: #374151;
color: #e5e7eb;
}
.field input:disabled {
background: #1f2937;
color: #6b7280;
}
.prefixed-input {
background: #111827;
border-color: #374151;
}
.input-prefix {
background: #1f2937;
border-right-color: #374151;
color: #d1d5db;
}
.btn {
background: #1f2937;
border-color: #374151;