Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 18 additions & 2 deletions frontend/src/components/CommandPalette.css
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,22 @@
.command-palette-search-icon {
color: #71717a;
flex-shrink: 0;
/* Explicit drag handle for the panel. Wails native window drag does not
support NSPanel yet, so dragging is implemented in JS (mousedown/move
-> spotlight:drag events) and applied via SetPosition on the backend.
The grab cursor makes the handle discoverable. */
cursor: grab;
}

/* SVG child elements (circle/line) have zero clientWidth/clientHeight, which
breaks offset-based hit checks. Route all pointer events to the svg root
so the drag handle behaves consistently. */
.command-palette-search-icon * {
pointer-events: none;
}

.command-palette-search-icon:active {
cursor: grabbing;
}

.command-palette-input {
Expand Down Expand Up @@ -59,7 +75,7 @@
}

.command-palette-results {
height: 264px; /* Results area: 80% of original (332 * 0.8) */
height: auto; /* Grows with the window; the JS backend drives window height */
overflow: hidden;
background: #18181b;
}
Expand All @@ -73,7 +89,7 @@

.command-palette-list {
overflow-y: auto;
max-height: 252px; /* 80% of original (320 * 0.8) */
max-height: calc(100vh - 49px); /* Scroll within the window; 49px = search box */
}

.command-palette-item {
Expand Down
69 changes: 65 additions & 4 deletions frontend/src/components/CommandPalette.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -212,6 +212,49 @@ export function CommandPalette() {
});
const inputRef = useRef(null);
const listRef = useRef(null);
const dragRef = useRef(null); // { startX, startY } in screen coords

// Custom panel dragging: Wails native window drag doesn't support NSPanel,
// so we track the pointer in JS and let the backend move the window.
const handleDragMouseDown = useCallback((e) => {
if (e.button !== 0) return;
dragRef.current = { startX: e.screenX, startY: e.screenY };
try {
Events.Emit('spotlight:drag:start');
} catch (err) {
console.error('Failed to emit spotlight:drag:start', err);
}
}, []);

useEffect(() => {
const onMouseMove = (e) => {
if (!dragRef.current) return;
const { startX, startY } = dragRef.current;
try {
Events.Emit('spotlight:drag', {
dx: e.screenX - startX,
dy: e.screenY - startY,
});
} catch (err) {
console.error('Failed to emit spotlight:drag', err);
}
};
const onMouseUp = () => {
if (!dragRef.current) return;
dragRef.current = null;
try {
Events.Emit('spotlight:drag:end');
} catch (err) {
console.error('Failed to emit spotlight:drag:end', err);
}
};
window.addEventListener('mousemove', onMouseMove);
window.addEventListener('mouseup', onMouseUp);
return () => {
window.removeEventListener('mousemove', onMouseMove);
window.removeEventListener('mouseup', onMouseUp);
};
}, []);

// Calculate fuzzy match score
const fuzzyScore = (target, query) => {
Expand Down Expand Up @@ -251,7 +294,7 @@ export function CommandPalette() {

// Listen for command palette opened event
useEffect(() => {
const unsubscribe = window.runtime?.EventsOn?.('command-palette:opened', () => {
const unsubscribe = window.runtime?.EventsOn?.('spotlight:opened', () => {
setSearchQuery('');
setSelectedIndex(0);
setTimeout(() => inputRef.current?.focus(), 100);
Expand All @@ -261,6 +304,20 @@ export function CommandPalette() {
};
}, []);

// Resize the spotlight window to fit the visible results (Spotlight-like
// dynamic height). The backend listens for spotlight:resize and resizes
// the NSPanel while keeping it centered.
useEffect(() => {
const itemCount = commands.length;
const shown = Math.min(itemCount, 12);
const height = Math.max(100, Math.min(600, 49 + shown * 37 + 2));
try {
Events.Emit('spotlight:resize', height);
} catch (err) {
console.error('Failed to emit spotlight:resize', err);
}
}, [commands.length]);

// Save recent command
const saveRecentCommand = useCallback((commandId) => {
setRecentCommands((prev) => {
Expand Down Expand Up @@ -323,9 +380,9 @@ export function CommandPalette() {
e.preventDefault();
console.log('[CommandPalette] Escape pressed, closing...');
try {
Events.Emit('command-palette:close');
Events.Emit('spotlight:close');
} catch (err) {
console.error('Failed to emit command-palette:close', err);
console.error('Failed to emit spotlight:close', err);
}
}
},
Expand All @@ -343,7 +400,11 @@ export function CommandPalette() {
return (
<div className="command-palette-container">
<div className="command-palette-search-box">
<Search size={20} className="command-palette-search-icon" />
<Search
size={20}
className="command-palette-search-icon"
onMouseDown={handleDragMouseDown}
/>
<input
ref={inputRef}
type="text"
Expand Down
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ require (
github.com/pelletier/go-toml/v2 v2.4.3
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e
github.com/stretchr/testify v1.11.1
github.com/wailsapp/wails/v3 v3.0.0-beta.6
github.com/wailsapp/wails/v3 v3.0.0-beta.11.0.20260821044253-44df631f198a
golang.design/x/hotkey v0.6.1
golang.org/x/crypto v0.54.0
golang.org/x/net v0.57.0
Expand Down
4 changes: 2 additions & 2 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -125,8 +125,8 @@ github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
github.com/ugorji/go/codec v1.3.1 h1:waO7eEiFDwidsBN6agj1vJQ4AG7lh2yqXyOXqhgQuyY=
github.com/ugorji/go/codec v1.3.1/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4=
github.com/wailsapp/wails/v3 v3.0.0-beta.6 h1:k9FHF/T39EyTZNCHweRrLt1c6dwV3R3A+r1oCNiPB8I=
github.com/wailsapp/wails/v3 v3.0.0-beta.6/go.mod h1:A/OaL1mXOnwWynTJv4rZU89Wbk5q3rtq3C3qkx4rRN0=
github.com/wailsapp/wails/v3 v3.0.0-beta.11.0.20260821044253-44df631f198a h1:oyvZVI2ubfK7jV7Q5sbaHsFPoEg3qPLiAeUA4HWoCgA=
github.com/wailsapp/wails/v3 v3.0.0-beta.11.0.20260821044253-44df631f198a/go.mod h1:zKZYhB3WjrN5LhJWbnOAVMN0Xf8qTozbw2nf5micKl4=
go.mongodb.org/mongo-driver/v2 v2.5.0 h1:yXUhImUjjAInNcpTcAlPHiT7bIXhshCTL3jVBkF3xaE=
go.mongodb.org/mongo-driver/v2 v2.5.0/go.mod h1:yOI9kBsufol30iFsl1slpdq1I0eHPzybRWdyYUs8K/0=
go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y=
Expand Down
24 changes: 24 additions & 0 deletions internal/settings/settings.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,10 @@ import (
// Settings holds the application settings
type Settings struct {
CloseMinimizesToTray bool `json:"closeMinimizesToTray"`
// SpotlightX/SpotlightY remember the last position of the spotlight
// panel. nil means the user has never moved it.
SpotlightX *int `json:"spotlightX,omitempty"`
SpotlightY *int `json:"spotlightY,omitempty"`
}

// Manager handles settings persistence
Expand Down Expand Up @@ -88,3 +92,23 @@ func (m *Manager) ToggleCloseMinimizesToTray() error {

return m.Save()
}

// GetSpotlightPosition returns the last remembered spotlight position.
// The bool is false when the user has never moved the panel.
func (m *Manager) GetSpotlightPosition() (int, int, bool) {
m.mu.RLock()
defer m.mu.RUnlock()
if m.settings.SpotlightX == nil || m.settings.SpotlightY == nil {
return 0, 0, false
}
return *m.settings.SpotlightX, *m.settings.SpotlightY, true
}

// SetSpotlightPosition remembers the spotlight panel position
func (m *Manager) SetSpotlightPosition(x, y int) error {
m.mu.Lock()
m.settings.SpotlightX = &x
m.settings.SpotlightY = &y
m.mu.Unlock()
return m.Save()
}
118 changes: 94 additions & 24 deletions main.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import (
"os"
"path/filepath"
"runtime"
"strconv"
"strings"
"time"

Expand Down Expand Up @@ -149,24 +150,28 @@ func main() {
windowControls.SetWindow(mainWindow)

// Create spotlight window with special behaviors
// Note: MacWindowLevelFloating and ActivationPolicyAccessory may require
// platform-specific code. CollectionBehaviors provide most spotlight functionality.
// NSPanel-backed window: non-activating (does not steal focus or activation
// from the previously active app) and floating, so it behaves like a real
// Spotlight/Raycast launcher overlay instead of a regular NSWindow.
spotlightWindow := app.Window.NewWithOptions(application.WebviewWindowOptions{
Title: "Spotlight",
Width: 640,
Height: 384,
MinHeight: 384,
MaxHeight: 384,
MinHeight: 100,
Frameless: true,
Hidden: true,
BackgroundColour: application.RGBA{Red: 22, Green: 22, Blue: 22, Alpha: 255},
// Center the window
InitialPosition: application.WindowCentered,
// Prevent resizing
DisableResize: true,
Mac: application.MacWindow{
// Float above other windows
WindowLevel: application.MacWindowLevelFloating,
// Use a dedicated NSPanel so showing or focusing the launcher does
// not activate DevToolbox or steal focus from the active app.
WindowClass: application.MacWindowClassPanel,
PanelPreferences: application.MacPanelPreferences{
NonActivating: true,
FloatingPanel: true,
BecomesKeyOnlyIfNeeded: true,
},
// Hidden title bar for clean look
TitleBar: application.MacTitleBar{
AppearsTransparent: true,
Expand All @@ -179,11 +184,34 @@ func main() {
// Set the window in spotlight service
spotlightService.SetWindow(spotlightWindow)

// Position the panel: stick to the last position the user dragged it to,
// or fall back to the default position on first run.
if x, y, ok := settingsManager.GetSpotlightPosition(); ok {
spotlightWindow.SetPosition(x, y)
} else {
positionSpotlight(spotlightWindow)
}

// Remember the panel position whenever it moves (e.g. user drags it), so
// the next open sticks to the latest position. Debounced at 200ms to avoid
// writing settings on every mousemove during a drag.
var lastSpotlightSave time.Time
spotlightWindow.OnWindowEvent(events.Common.WindowDidMove, func(event *application.WindowEvent) {
now := time.Now()
if now.Sub(lastSpotlightSave) < 200*time.Millisecond {
return
}
lastSpotlightSave = now
x, y := spotlightWindow.Position()
if err := settingsManager.SetSpotlightPosition(x, y); err != nil {
log.Printf("[Spotlight] Failed to save position: %v", err)
}
})

// Handle spotlight window close - hide instead of close
spotlightWindow.OnWindowEvent(events.Common.WindowClosing, func(event *application.WindowEvent) {
event.Cancel()
spotlightWindow.Hide()
spotlightWindow.EmitEvent("spotlight:closed", "")
})

// Listen for spotlight navigation events
Expand Down Expand Up @@ -217,12 +245,9 @@ func main() {
mainWindow.Show()
mainWindow.Focus()

// Hide spotlight window asynchronously to prevent macOS from reverting focus
// to the previously active non-DevToolbox app
go func() {
time.Sleep(100 * time.Millisecond)
spotlightWindow.Hide()
}()
// Hide the panel. Non-activating NSPanels never took over app activation,
// so hiding immediately cannot make macOS revert focus to another app.
spotlightWindow.Hide()

// Tell the frontend to navigate
mainWindow.EmitEvent("navigate:to", path)
Expand All @@ -234,10 +259,46 @@ func main() {
spotlightWindow.Hide()
})

// Proxy these events to the main window
app.Event.On("spotlight:theme:toggle", func(_ *application.CustomEvent) {
log.Printf("[Spotlight] Relaying theme:toggle to main window")
mainWindow.EmitEvent("theme:toggle", nil)
// Resize spotlight window to fit content (height driven by the frontend,
// which knows how many results are visible). Keeps the panel centered.
app.Event.On("spotlight:resize", func(event *application.CustomEvent) {
var h float64
switch v := event.Data.(type) {
case float64:
h = v
case int:
h = float64(v)
case string:
if parsed, err := strconv.ParseFloat(v, 64); err == nil {
h = parsed
}
}
if h > 0 {
spotlightWindow.SetSize(640, int(h))
}
})

// Custom panel dragging. Wails native window drag (startDrag) does not
// support NSPanel yet, so the frontend tracks the pointer and we move the
// window here with SetPosition.
var dragOriginX, dragOriginY int
app.Event.On("spotlight:drag:start", func(_ *application.CustomEvent) {
dragOriginX, dragOriginY = spotlightWindow.Position()
})
app.Event.On("spotlight:drag", func(event *application.CustomEvent) {
m, ok := event.Data.(map[string]interface{})
if !ok {
return
}
dx, _ := m["dx"].(float64)
dy, _ := m["dy"].(float64)
spotlightWindow.SetPosition(dragOriginX+int(dx), dragOriginY+int(dy))
})
app.Event.On("spotlight:drag:end", func(_ *application.CustomEvent) {
x, y := spotlightWindow.Position()
if err := settingsManager.SetSpotlightPosition(x, y); err != nil {
log.Printf("[Spotlight] Failed to save drag position: %v", err)
}
})

app.Event.On("window:toggle", func(_ *application.CustomEvent) {
Expand All @@ -250,11 +311,6 @@ func main() {
}
})

app.Event.On("app:quit", func(_ *application.CustomEvent) {
log.Printf("[Spotlight] App quit requested via spotlight")
app.Quit()
})

// Setup system tray
systray := app.SystemTray.New()

Expand Down Expand Up @@ -296,6 +352,20 @@ func main() {
}
}

// positionSpotlight keeps the spotlight panel horizontally centered while its
// top edge stays at a fixed height below the top of the screen, so resizing
// makes it grow downward instead of re-centering (macOS Spotlight behaviour).
func positionSpotlight(w *application.WebviewWindow) {
screen, err := w.GetScreen()
if err != nil || screen == nil {
w.Center()
return
}
x := screen.Bounds.X + (screen.Bounds.Width-640)/2
y := screen.Bounds.Y + int(float64(screen.Bounds.Height)*0.15)
w.SetPosition(x, y)
}

func GinMiddleware(ginEngine *gin.Engine) application.Middleware {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
Expand Down
Loading