diff --git a/app.go b/app.go index d0b682f..9bd1565 100644 --- a/app.go +++ b/app.go @@ -24,6 +24,7 @@ import ( "github.com/mmmy/snapgo/internal/infrastructure/hotkey" "github.com/mmmy/snapgo/internal/infrastructure/oss" "github.com/mmmy/snapgo/internal/infrastructure/screencapture" + sshpkg "github.com/mmmy/snapgo/internal/infrastructure/ssh" ) // App is the struct exposed to the Wails frontend through Bind. @@ -315,6 +316,38 @@ func (a *App) runSaveImagePipeline(pngBytes []byte, dir string) (string, error) return svc.SaveImage(a.ctx, pngBytes, dir) } +// runSaveRemotePipeline uploads the captured PNG to the configured SSH host +// via SCP and copies a sharable reference (URL or "user@host:~/path") to +// the clipboard. +// +// Why a dedicated method (vs. extending CaptureAndUploadService): SSH and +// S3 have different config + clipboard semantics, so keeping them separate +// avoids leaking provider-specific branches into the OSS pipeline. +func (a *App) runSaveRemotePipeline(pngBytes []byte) error { + a.mu.RLock() + cfg := a.cfg + a.mu.RUnlock() + + if !cfg.IsSSHConfigured() { + err := fmt.Errorf("SSH host/user is not configured") + slog.Warn("save-remote rejected: ssh not configured", + "host", cfg.SSH.Host, "user", cfg.SSH.User) + wruntime.EventsEmit(a.ctx, "upload:failure", err.Error()) + return err + } + slog.Info("save-remote dispatch", + "host", cfg.SSH.Host, "user", cfg.SSH.User, "port", cfg.SSH.Port, + "png_size", len(pngBytes)) + + svc := &application.CaptureAndSSHService{ + Uploader: sshpkg.NewUploader(cfg.SSH), + Clipboard: a.clip, + Notifier: &runtimeNotifier{ctx: a.ctx}, + Cfg: cfg.SSH, + } + return svc.ExecuteWithBytes(a.ctx, pngBytes) +} + func (a *App) consumePendingCapture() (*pendingCapture, error) { a.pendingMu.Lock() pc := a.pending @@ -407,6 +440,20 @@ func (a *App) TestConnection(cfg domain.S3Config) error { return provider.TestConnection(a.ctx) } +// TestSSHConnection performs a minimal SSH handshake probe against the +// supplied configuration so the SettingsView's "Test connection" button +// can give the user immediate feedback. +func (a *App) TestSSHConnection(cfg domain.SSHConfig) error { + slog.Info("RPC TestSSHConnection", + "host", cfg.Host, "user", cfg.User, "port", cfg.Port, + "strict_host_key", cfg.StrictHostKey, "has_password", cfg.Password != "") + if err := sshpkg.TestConnection(a.ctx, cfg); err != nil { + slog.Error("RPC TestSSHConnection failed", "err", err) + return err + } + return nil +} + // CaptureNow is the in-app trigger. func (a *App) CaptureNow() { go a.runInteractiveCapture() @@ -604,6 +651,70 @@ func (a *App) SaveNativeRegionImageToDir(result CaptureResult, dir string) (Capt return CaptureActionResult{Completed: true, Path: path}, nil } +// SaveRegionToRemote uploads the user-selected region via SCP to the +// configured SSH host. Driven by the Wails overlay's "save remote" button. +// +// The flow mirrors ConfirmRegion (S3 upload) but routes through +// runSaveRemotePipeline instead. We dismiss the overlay first so the +// upload progress (and any error toast) shows over the regular settings UI. +func (a *App) SaveRegionToRemote(result CaptureResult) error { + pc, err := a.consumePendingCapture() + if err != nil { + slog.Warn("SaveRegionToRemote: no pending capture", "err", err) + return err + } + defer func() { + a.capturing.Store(false) + a.dismissOverlay() + }() + + a.dismissOverlay() + flushFrame() + + slog.Info("SaveRegionToRemote: capturing region", + "x", result.Rect.X, "y", result.Rect.Y, + "w", result.Rect.W, "h", result.Rect.H, + "annotations", len(result.Annotations)) + cropped, err := a.captureSelectedPNG(result, pc) + if err != nil { + slog.Error("SaveRegionToRemote: capture failed", "err", err) + wruntime.EventsEmit(a.ctx, "upload:failure", err.Error()) + return err + } + slog.Debug("SaveRegionToRemote: capture ok", "png_bytes", len(cropped)) + return a.runSaveRemotePipeline(cropped) +} + +// SaveNativeRegionToRemote is the macOS-native overlay equivalent of +// SaveRegionToRemote. The AppKit panel is already closed by the time this +// runs (see saveRemoteSelection in native_overlay_darwin.go), so we only +// have to release the dock icon afterwards. +func (a *App) SaveNativeRegionToRemote(result CaptureResult) error { + pc, err := a.consumePendingCapture() + if err != nil { + slog.Warn("SaveNativeRegionToRemote: no pending capture", "err", err) + return err + } + defer func() { + a.capturing.Store(false) + hideDockIcon() + }() + + flushFrame() + slog.Info("SaveNativeRegionToRemote: capturing region", + "x", result.Rect.X, "y", result.Rect.Y, + "w", result.Rect.W, "h", result.Rect.H, + "annotations", len(result.Annotations)) + cropped, err := a.captureSelectedPNG(result, pc) + if err != nil { + slog.Error("SaveNativeRegionToRemote: capture failed", "err", err) + wruntime.EventsEmit(a.ctx, "upload:failure", err.Error()) + return err + } + slog.Debug("SaveNativeRegionToRemote: capture ok", "png_bytes", len(cropped)) + return a.runSaveRemotePipeline(cropped) +} + func parseNativeAnnotations(raw string) []application.Annotation { if raw == "" { return nil diff --git a/frontend/src/App.vue b/frontend/src/App.vue index d3d6fb8..ce21284 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -23,6 +23,7 @@ import { ConfirmRegion, CopyRegionImage, SaveRegionImage, + SaveRegionToRemote, CancelRegion, } from '../wailsjs/go/main/App' @@ -35,6 +36,20 @@ const hotkeyStatus = ref({ state: 'unknown' }) type Mode = 'settings' | 'overlay' const mode = ref('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('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(() => {
- +
@@ -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; diff --git a/frontend/src/views/CaptureOverlay.vue b/frontend/src/views/CaptureOverlay.vue index 340f945..73e785b 100644 --- a/frontend/src/views/CaptureOverlay.vue +++ b/frontend/src/views/CaptureOverlay.vue @@ -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" /> +