Skip to content

Commit f34fb03

Browse files
DavidsonGomesclaude
andcommitted
release: v0.20.3 — licensing fix + file tab context menu + scheduler systemd
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 928b121 commit f34fb03

9 files changed

Lines changed: 252 additions & 100 deletions

File tree

CHANGELOG.md

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,21 @@ All notable changes to this project will be documented in this file.
55
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
66
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
77

8+
## [0.20.3] - 2026-04-13
9+
10+
### Added
11+
12+
- **File tab context menu** — right-click on workspace file tabs for: Close, Close others, Close all to the left, Close all to the right, Close all
13+
- **Scheduler in systemd**`start-services.sh` and `ExecStop` now manage the scheduler process. Restarts properly kill and relaunch the scheduler so `routines.yaml` changes take effect
14+
15+
### Fixed
16+
17+
- **Licensing product slug** — changed `PRODUCT` and `TIER` from `"evonexus"` to `"evo-nexus"` to match the licensing server's product registry. This was causing 400 `INVALID_TIER` on new installations
18+
- **Licensing error logging**`_post()` now logs the server's error body (e.g., `MISSING_FIELD: email is required`) instead of the generic `400 Bad Request`
19+
- **Setup requires email** — the initial setup endpoint now validates that email is provided (required for license registration)
20+
- **Auto-register skips missing email**`auto_register_if_needed()` no longer attempts registration if the admin user has no email
21+
- **Makefile pkill self-kill** — applied `[p]attern` bracket trick to prevent `pkill -f` from matching its own shell process on Linux/WSL (PR #5 by @gomessguii)
22+
823
## [0.20.2] - 2026-04-13
924

1025
### Added

cli/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@evoapi/evo-nexus",
3-
"version": "0.20.2",
3+
"version": "0.20.3",
44
"description": "Unofficial open source toolkit for Claude Code — AI-powered business operating system",
55
"keywords": [
66
"claude-code",

dashboard/backend/licensing.py

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -20,8 +20,8 @@
2020
logger = logging.getLogger("licensing")
2121

2222
LICENSING_SERVER = "https://license.evolutionfoundation.com.br"
23-
PRODUCT = "evonexus"
24-
TIER = "evonexus"
23+
PRODUCT = "evo-nexus"
24+
TIER = "evo-nexus"
2525
TIMEOUT = 10
2626

2727

@@ -103,7 +103,12 @@ def _post(path: str, payload: dict, api_key: str | None = None) -> dict:
103103
headers["Authorization"] = f"HMAC {signature}"
104104

105105
resp = requests.post(url, data=body, headers=headers, timeout=TIMEOUT)
106-
resp.raise_for_status()
106+
if not resp.ok:
107+
try:
108+
detail = resp.json().get("detail", resp.text[:200])
109+
except Exception:
110+
detail = resp.text[:200]
111+
raise requests.HTTPError(f"{resp.status_code} {resp.reason}: {detail}", response=resp)
107112
return resp.json()
108113

109114

@@ -270,7 +275,7 @@ def auto_register_if_needed():
270275
return
271276

272277
admin = User.query.filter_by(role="admin").first()
273-
if not admin:
278+
if not admin or not admin.email:
274279
return
275280

276281
if not instance_id:

dashboard/backend/routes/auth_routes.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,8 @@ def setup():
4747

4848
if not username or not password:
4949
abort(400, description="Username and password are required")
50+
if not email:
51+
abort(400, description="Email is required for license registration")
5052
if len(password) < 6:
5153
abort(400, description="Password must be at least 6 characters")
5254

Lines changed: 176 additions & 88 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { useRef } from 'react'
1+
import { useRef, useState, useEffect, useCallback } from 'react'
22
import { X } from 'lucide-react'
33

44
export interface TabEntry {
@@ -11,112 +11,200 @@ interface FileTabsProps {
1111
dirtyPaths: Set<string>
1212
onSwitch: (path: string) => void
1313
onClose: (path: string) => void
14+
onCloseAll?: () => void
15+
onCloseOthers?: (path: string) => void
16+
onCloseToLeft?: (path: string) => void
17+
onCloseToRight?: (path: string) => void
1418
}
1519

1620
function fileName(path: string): string {
1721
return path.split('/').pop() ?? path
1822
}
1923

20-
export default function FileTabs({ tabs, activePath, dirtyPaths, onSwitch, onClose }: FileTabsProps) {
24+
interface ContextMenu {
25+
x: number
26+
y: number
27+
tabPath: string
28+
tabIndex: number
29+
}
30+
31+
export default function FileTabs({ tabs, activePath, dirtyPaths, onSwitch, onClose, onCloseAll, onCloseOthers, onCloseToLeft, onCloseToRight }: FileTabsProps) {
2132
const scrollRef = useRef<HTMLDivElement>(null)
33+
const [ctxMenu, setCtxMenu] = useState<ContextMenu | null>(null)
2234

23-
if (tabs.length === 0) return null
35+
// Close context menu on click outside or Escape
36+
useEffect(() => {
37+
if (!ctxMenu) return
38+
const handleClose = () => setCtxMenu(null)
39+
const handleKey = (e: KeyboardEvent) => { if (e.key === 'Escape') setCtxMenu(null) }
40+
document.addEventListener('click', handleClose)
41+
document.addEventListener('keydown', handleKey)
42+
return () => {
43+
document.removeEventListener('click', handleClose)
44+
document.removeEventListener('keydown', handleKey)
45+
}
46+
}, [ctxMenu])
2447

25-
return (
26-
<div
27-
ref={scrollRef}
28-
className="flex items-end overflow-x-auto flex-shrink-0"
29-
style={{
30-
background: 'var(--bg-card)',
31-
borderBottom: '1px solid var(--border)',
32-
scrollbarWidth: 'none',
33-
msOverflowStyle: 'none',
34-
minHeight: '36px',
35-
}}
36-
>
37-
<style>{`
38-
.file-tabs-scroll::-webkit-scrollbar { display: none; }
39-
.file-tab-close { opacity: 0; }
40-
.file-tab:hover .file-tab-close,
41-
.file-tab.active .file-tab-close { opacity: 1; }
42-
`}</style>
43-
{tabs.map(tab => {
44-
const isActive = tab.path === activePath
45-
const isDirty = dirtyPaths.has(tab.path)
48+
const handleContextMenu = useCallback((e: React.MouseEvent, tabPath: string, tabIndex: number) => {
49+
e.preventDefault()
50+
setCtxMenu({ x: e.clientX, y: e.clientY, tabPath, tabIndex })
51+
}, [])
4652

47-
return (
48-
<button
49-
key={tab.path}
50-
className={`file-tab flex items-center gap-1.5 px-3 py-2 text-xs flex-shrink-0 transition-colors relative select-none${isActive ? ' active' : ''}`}
51-
style={{
52-
maxWidth: '200px',
53-
minWidth: '80px',
54-
borderBottom: isActive ? '2px solid var(--evo-green)' : '2px solid transparent',
55-
color: isActive ? 'var(--text-primary)' : 'var(--text-muted)',
56-
background: isActive ? 'var(--surface-active)' : 'transparent',
57-
cursor: 'pointer',
58-
whiteSpace: 'nowrap',
59-
}}
60-
onClick={() => onSwitch(tab.path)}
61-
onMouseDown={e => {
62-
if (e.button === 1) {
63-
e.preventDefault()
64-
onClose(tab.path)
65-
}
66-
}}
67-
onMouseEnter={e => {
68-
if (!isActive) e.currentTarget.style.background = 'var(--surface-hover)'
69-
}}
70-
onMouseLeave={e => {
71-
if (!isActive) e.currentTarget.style.background = 'transparent'
72-
}}
73-
title={tab.path}
74-
>
75-
{/* Dirty indicator dot */}
76-
{isDirty && (
77-
<span
78-
style={{
79-
width: '6px',
80-
height: '6px',
81-
borderRadius: '50%',
82-
background: 'var(--warning)',
83-
flexShrink: 0,
84-
display: 'inline-block',
85-
}}
86-
/>
87-
)}
53+
if (tabs.length === 0) return null
8854

89-
{/* File name */}
90-
<span
91-
className="truncate flex-1 min-w-0"
92-
style={{ maxWidth: isDirty ? '130px' : '150px' }}
93-
>
94-
{fileName(tab.path)}
95-
</span>
55+
const hasLeft = ctxMenu ? ctxMenu.tabIndex > 0 : false
56+
const hasRight = ctxMenu ? ctxMenu.tabIndex < tabs.length - 1 : false
57+
const hasOthers = tabs.length > 1
58+
59+
return (
60+
<>
61+
<div
62+
ref={scrollRef}
63+
className="flex items-end overflow-x-auto flex-shrink-0 file-tabs-scroll"
64+
style={{
65+
background: 'var(--bg-card)',
66+
borderBottom: '1px solid var(--border)',
67+
scrollbarWidth: 'none',
68+
msOverflowStyle: 'none',
69+
minHeight: '36px',
70+
}}
71+
>
72+
<style>{`
73+
.file-tabs-scroll::-webkit-scrollbar { display: none; }
74+
.file-tab-close { opacity: 0; }
75+
.file-tab:hover .file-tab-close,
76+
.file-tab.active .file-tab-close { opacity: 1; }
77+
`}</style>
78+
{tabs.map((tab, index) => {
79+
const isActive = tab.path === activePath
80+
const isDirty = dirtyPaths.has(tab.path)
9681

97-
{/* Close button */}
98-
<span
99-
className="file-tab-close flex items-center justify-center flex-shrink-0 rounded transition-colors"
82+
return (
83+
<button
84+
key={tab.path}
85+
className={`file-tab flex items-center gap-1.5 px-3 py-2 text-xs flex-shrink-0 transition-colors relative select-none${isActive ? ' active' : ''}`}
10086
style={{
101-
width: '16px',
102-
height: '16px',
87+
maxWidth: '200px',
88+
minWidth: '80px',
89+
borderBottom: isActive ? '2px solid var(--evo-green)' : '2px solid transparent',
90+
color: isActive ? 'var(--text-primary)' : 'var(--text-muted)',
91+
background: isActive ? 'var(--surface-active)' : 'transparent',
92+
cursor: 'pointer',
93+
whiteSpace: 'nowrap',
10394
}}
104-
onClick={e => {
105-
e.stopPropagation()
106-
onClose(tab.path)
95+
onClick={() => onSwitch(tab.path)}
96+
onContextMenu={e => handleContextMenu(e, tab.path, index)}
97+
onMouseDown={e => {
98+
if (e.button === 1) {
99+
e.preventDefault()
100+
onClose(tab.path)
101+
}
107102
}}
108103
onMouseEnter={e => {
109-
e.currentTarget.style.background = 'var(--border)'
104+
if (!isActive) e.currentTarget.style.background = 'var(--surface-hover)'
110105
}}
111106
onMouseLeave={e => {
112-
e.currentTarget.style.background = 'transparent'
107+
if (!isActive) e.currentTarget.style.background = 'transparent'
113108
}}
109+
title={tab.path}
114110
>
115-
<X size={10} />
116-
</span>
117-
</button>
118-
)
119-
})}
120-
</div>
111+
{isDirty && (
112+
<span
113+
style={{
114+
width: '6px',
115+
height: '6px',
116+
borderRadius: '50%',
117+
background: 'var(--warning)',
118+
flexShrink: 0,
119+
display: 'inline-block',
120+
}}
121+
/>
122+
)}
123+
124+
<span
125+
className="truncate flex-1 min-w-0"
126+
style={{ maxWidth: isDirty ? '130px' : '150px' }}
127+
>
128+
{fileName(tab.path)}
129+
</span>
130+
131+
<span
132+
className="file-tab-close flex items-center justify-center flex-shrink-0 rounded transition-colors"
133+
style={{ width: '16px', height: '16px' }}
134+
onClick={e => {
135+
e.stopPropagation()
136+
onClose(tab.path)
137+
}}
138+
onMouseEnter={e => { e.currentTarget.style.background = 'var(--border)' }}
139+
onMouseLeave={e => { e.currentTarget.style.background = 'transparent' }}
140+
>
141+
<X size={10} />
142+
</span>
143+
</button>
144+
)
145+
})}
146+
</div>
147+
148+
{/* Context menu */}
149+
{ctxMenu && (
150+
<div
151+
className="fixed z-[100] rounded-lg border shadow-xl py-1"
152+
style={{
153+
left: ctxMenu.x,
154+
top: ctxMenu.y,
155+
background: 'var(--bg-card)',
156+
borderColor: 'var(--border)',
157+
minWidth: '200px',
158+
boxShadow: '0 8px 32px rgba(0,0,0,0.4)',
159+
}}
160+
>
161+
<CtxMenuItem
162+
label="Fechar"
163+
shortcut="Ctrl+W"
164+
onClick={() => { onClose(ctxMenu.tabPath); setCtxMenu(null) }}
165+
/>
166+
{hasOthers && onCloseOthers && (
167+
<CtxMenuItem
168+
label="Fechar outras"
169+
onClick={() => { onCloseOthers(ctxMenu.tabPath); setCtxMenu(null) }}
170+
/>
171+
)}
172+
{hasLeft && onCloseToLeft && (
173+
<CtxMenuItem
174+
label="Fechar todas à esquerda"
175+
onClick={() => { onCloseToLeft(ctxMenu.tabPath); setCtxMenu(null) }}
176+
/>
177+
)}
178+
{hasRight && onCloseToRight && (
179+
<CtxMenuItem
180+
label="Fechar todas à direita"
181+
onClick={() => { onCloseToRight(ctxMenu.tabPath); setCtxMenu(null) }}
182+
/>
183+
)}
184+
<div style={{ borderTop: '1px solid var(--border)', margin: '4px 0' }} />
185+
{onCloseAll && (
186+
<CtxMenuItem
187+
label="Fechar todas"
188+
onClick={() => { onCloseAll(); setCtxMenu(null) }}
189+
/>
190+
)}
191+
</div>
192+
)}
193+
</>
194+
)
195+
}
196+
197+
function CtxMenuItem({ label, shortcut, onClick }: { label: string; shortcut?: string; onClick: () => void }) {
198+
return (
199+
<button
200+
className="w-full flex items-center justify-between px-3 py-1.5 text-xs transition-colors"
201+
style={{ color: 'var(--text-secondary)' }}
202+
onClick={onClick}
203+
onMouseEnter={e => { e.currentTarget.style.background = 'var(--surface-hover)'; e.currentTarget.style.color = 'var(--text-primary)' }}
204+
onMouseLeave={e => { e.currentTarget.style.background = 'transparent'; e.currentTarget.style.color = 'var(--text-secondary)' }}
205+
>
206+
<span>{label}</span>
207+
{shortcut && <span style={{ color: 'var(--text-muted)', fontSize: '10px' }}>{shortcut}</span>}
208+
</button>
121209
)
122210
}

0 commit comments

Comments
 (0)