Skip to content

Commit eb110d5

Browse files
committed
Add :q quit command and fix theme bugs
- Add :q command for vim-style client quitting - Disable ESC from quitting while preserving menu functionality - Fix case-insensitive theme lookup and persistence issues - Update README with :q documentation and v0.9.0-beta.2 links
1 parent 0af1ac7 commit eb110d5

File tree

3 files changed

+148
-45
lines changed

3 files changed

+148
-45
lines changed

README.md

Lines changed: 8 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,6 @@ Full changelog on [GitHub releases](https://github.com/Cod-e-Codes/marchat/relea
4545

4646
- **Terminal UI** - Beautiful TUI built with Bubble Tea
4747
- **Real-time Chat** - Fast WebSocket messaging with SQLite backend (PostgreSQL/MySQL planned)
48-
- **Database Performance** - SQLite WAL mode for improved concurrency and performance (creates additional `.db-wal` and `.db-shm` files)
4948
- **Plugin System** - Remote registry with text commands and Alt+key hotkeys
5049
- **E2E Encryption** - X25519/ChaCha20-Poly1305 with global encryption
5150
- **File Sharing** - Send files up to 1MB (configurable) with interactive picker
@@ -111,22 +110,6 @@ export MARCHAT_USERS="admin1,admin2"
111110
./marchat-client
112111
```
113112

114-
## Try the Demo
115-
116-
Want to test marchat without setting up your own server? Try our public demo:
117-
118-
```bash
119-
# Connect to the demo server (unencrypted, not for production)
120-
./marchat-client --username your-username --server wss://marchat.Cod-e-Codes.com/ws
121-
```
122-
123-
**Demo Server Details:**
124-
- **URL**: `wss://marchat.Cod-e-Codes.com/ws`
125-
- **Status**: Public demo (not for production use)
126-
- **Encryption**: Disabled (unencrypted)
127-
- **Data**: Will be wiped periodically
128-
- **Purpose**: Testing and demonstration only
129-
130113
## Database Schema
131114

132115
Key tables for message tracking and moderation:
@@ -139,24 +122,24 @@ Key tables for message tracking and moderation:
139122
**Binary Installation:**
140123
```bash
141124
# Linux (amd64)
142-
wget https://github.com/Cod-e-Codes/marchat/releases/download/v0.9.0-beta.1/marchat-v0.9.0-beta.1-linux-amd64.zip
143-
unzip marchat-v0.9.0-beta.1-linux-amd64.zip && chmod +x marchat-*
125+
wget https://github.com/Cod-e-Codes/marchat/releases/download/v0.9.0-beta.2/marchat-v0.9.0-beta.2-linux-amd64.zip
126+
unzip marchat-v0.9.0-beta.2-linux-amd64.zip && chmod +x marchat-*
144127

145128
# macOS (amd64)
146-
wget https://github.com/Cod-e-Codes/marchat/releases/download/v0.9.0-beta.1/marchat-v0.9.0-beta.1-darwin-amd64.zip
147-
unzip marchat-v0.9.0-beta.1-darwin-amd64.zip && chmod +x marchat-*
129+
wget https://github.com/Cod-e-Codes/marchat/releases/download/v0.9.0-beta.2/marchat-v0.9.0-beta.2-darwin-amd64.zip
130+
unzip marchat-v0.9.0-beta.2-darwin-amd64.zip && chmod +x marchat-*
148131

149132
# Windows - PowerShell
150133
iwr -useb https://raw.githubusercontent.com/Cod-e-Codes/marchat/main/install.ps1 | iex
151134
```
152135

153136
**Docker:**
154137
```bash
155-
docker pull codecodesxyz/marchat:v0.9.0-beta.1
138+
docker pull codecodesxyz/marchat:v0.9.0-beta.2
156139
docker run -d -p 8080:8080 \
157140
-e MARCHAT_ADMIN_KEY=$(openssl rand -hex 32) \
158141
-e MARCHAT_USERS=admin1,admin2 \
159-
codecodesxyz/marchat:v0.9.0-beta.1
142+
codecodesxyz/marchat:v0.9.0-beta.2
160143
```
161144

162145
**From Source:**
@@ -219,6 +202,7 @@ go build -o marchat-client ./client
219202
| `:themes` | List all available themes | - |
220203
| `:time` | Toggle 12/24-hour format | `Alt+T` |
221204
| `:clear` | Clear chat buffer | `Ctrl+L` |
205+
| `:q` | Quit client | - |
222206
| `:sendfile [path]` | Send file (or open picker without path) | `Alt+F` |
223207
| `:savefile <name>` | Save received file | - |
224208
| `:code` | Open code composer with syntax highlighting | `Alt+C` |
@@ -270,7 +254,7 @@ Navigate with arrow keys, Enter to select/open folders, ".. (Parent Directory)"
270254
|-----|--------|
271255
| `Ctrl+H` | Toggle help overlay |
272256
| `Enter` | Send message |
273-
| `Esc` | Quit / Close menus |
257+
| `Esc` | Close menus |
274258
| `↑/↓` | Scroll chat |
275259
| `PgUp/PgDn` | Page through chat |
276260
| `Ctrl+C/V/X/A` | Copy/Paste/Cut/Select all |

client/main.go

Lines changed: 113 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -52,19 +52,20 @@ var urlRegex *regexp.Regexp
5252

5353
// keyMap defines all keybindings for the help system
5454
type keyMap struct {
55-
Send key.Binding
56-
ScrollUp key.Binding
57-
ScrollDown key.Binding
58-
PageUp key.Binding
59-
PageDown key.Binding
60-
Copy key.Binding
61-
Paste key.Binding
62-
Cut key.Binding
63-
SelectAll key.Binding
64-
Help key.Binding
65-
Quit key.Binding
66-
TimeFormat key.Binding
67-
Clear key.Binding
55+
Send key.Binding
56+
ScrollUp key.Binding
57+
ScrollDown key.Binding
58+
PageUp key.Binding
59+
PageDown key.Binding
60+
Copy key.Binding
61+
Paste key.Binding
62+
Cut key.Binding
63+
SelectAll key.Binding
64+
Help key.Binding
65+
Quit key.Binding
66+
TimeFormat key.Binding
67+
Clear key.Binding
68+
QuitCommand key.Binding
6869
// Commands with both text commands and hotkey alternatives
6970
SendFile key.Binding
7071
SaveFile key.Binding
@@ -176,7 +177,7 @@ func newKeyMap() keyMap {
176177
),
177178
Quit: key.NewBinding(
178179
key.WithKeys("esc"),
179-
key.WithHelp("esc", "quit"),
180+
key.WithHelp("esc", "close menus"),
180181
),
181182
TimeFormat: key.NewBinding(
182183
key.WithKeys(":time"),
@@ -186,6 +187,10 @@ func newKeyMap() keyMap {
186187
key.WithKeys(":clear"),
187188
key.WithHelp(":clear", "clear chat history"),
188189
),
190+
QuitCommand: key.NewBinding(
191+
key.WithKeys(":q"),
192+
key.WithHelp(":q", "quit client"),
193+
),
189194
SendFile: key.NewBinding(
190195
key.WithKeys(":sendfile"),
191196
key.WithHelp(":sendfile <path>", "send a file"),
@@ -572,9 +577,62 @@ func debugWebSocketWrite(ws *websocket.Conn, msg interface{}) error {
572577
return ws.WriteJSON(msg)
573578
}
574579

580+
// getCurrentProfileName finds the profile name that matches the current config
581+
func getCurrentProfileName(cfg *config.Config) string {
582+
loader, err := config.NewInteractiveConfigLoader()
583+
if err != nil {
584+
return "" // Can't determine profile name
585+
}
586+
587+
profiles, err := loader.LoadProfiles()
588+
if err != nil {
589+
return "" // Can't load profiles
590+
}
591+
592+
// Find matching profile
593+
for _, profile := range profiles.Profiles {
594+
if profile.Username == cfg.Username &&
595+
profile.ServerURL == cfg.ServerURL &&
596+
profile.IsAdmin == cfg.IsAdmin &&
597+
profile.UseE2E == cfg.UseE2E {
598+
return profile.Name
599+
}
600+
}
601+
602+
return "" // No matching profile found
603+
}
604+
605+
// updateProfileTheme updates the theme in the corresponding profile in profiles.json
606+
func (m *model) updateProfileTheme(newTheme string) error {
607+
if m.profileName == "" {
608+
return nil // No profile to update
609+
}
610+
611+
loader, err := config.NewInteractiveConfigLoader()
612+
if err != nil {
613+
return err
614+
}
615+
616+
profiles, err := loader.LoadProfiles()
617+
if err != nil {
618+
return err
619+
}
620+
621+
// Find and update the matching profile
622+
for i, profile := range profiles.Profiles {
623+
if profile.Name == m.profileName {
624+
profiles.Profiles[i].Theme = newTheme
625+
return loader.SaveProfiles(profiles)
626+
}
627+
}
628+
629+
return nil // Profile not found, nothing to update
630+
}
631+
575632
type model struct {
576633
cfg config.Config
577634
configFilePath string // Store the config file path for saving
635+
profileName string // Store the current profile name for updating profiles.json
578636
textarea textarea.Model
579637
viewport viewport.Model
580638
messages []shared.Message
@@ -1439,8 +1497,8 @@ func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
14391497
m.selectedUser = ""
14401498
return m, nil
14411499
}
1442-
m.closeWebSocket()
1443-
return m, tea.Quit
1500+
// ESC no longer quits - use :q command instead
1501+
return m, nil
14441502
case key.Matches(v, m.keys.DatabaseMenu):
14451503
// Only show database menu if admin and no other menus are open
14461504
if *isAdmin && !m.showHelp {
@@ -1515,13 +1573,21 @@ func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
15151573
}
15161574
}
15171575
nextIndex := (currentIndex + 1) % len(themes)
1518-
m.cfg.Theme = themes[nextIndex]
1576+
newTheme := themes[nextIndex]
1577+
m.cfg.Theme = newTheme
15191578
m.styles = getThemeStyles(m.cfg.Theme)
15201579
_ = config.SaveConfig(m.configFilePath, m.cfg)
15211580

1581+
// Update profile with new theme
1582+
_ = m.updateProfileTheme(newTheme)
1583+
15221584
// Show theme info in banner
15231585
themeInfo := GetThemeInfo(m.cfg.Theme)
15241586
m.banner = fmt.Sprintf("Theme: %s", themeInfo)
1587+
1588+
// Redraw viewport and user list with new theme
1589+
m.viewport.SetContent(renderMessages(m.messages, m.styles, m.cfg.Username, m.users, m.viewport.Width, m.twentyFourHour))
1590+
m.userListViewport.SetContent(renderUserList(m.users, m.cfg.Username, m.styles, m.width, *isAdmin, m.selectedUserIndex))
15251591
return m, nil
15261592
case key.Matches(v, m.keys.TimeFormatHotkey):
15271593
// Toggle time format
@@ -1926,23 +1992,43 @@ func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
19261992
if len(parts) == 2 && strings.TrimSpace(parts[1]) != "" {
19271993
themeName := strings.TrimSpace(parts[1])
19281994

1929-
// Check if theme exists
1995+
// Check if theme exists using case-insensitive lookup
19301996
allThemes := ListAllThemes()
19311997
themeExists := false
1998+
actualThemeName := themeName
1999+
2000+
// First try exact match
19322001
for _, t := range allThemes {
19332002
if t == themeName {
19342003
themeExists = true
2004+
actualThemeName = t
19352005
break
19362006
}
19372007
}
19382008

2009+
// If not found, try case-insensitive match for custom themes
2010+
if !themeExists {
2011+
if key, _, found := GetCustomThemeByName(themeName); found {
2012+
themeExists = true
2013+
actualThemeName = key
2014+
}
2015+
}
2016+
19392017
if !themeExists {
19402018
m.banner = fmt.Sprintf("Theme '%s' not found. Use :themes to list available themes.", themeName)
19412019
} else {
1942-
m.cfg.Theme = themeName
2020+
m.cfg.Theme = actualThemeName
19432021
m.styles = getThemeStyles(m.cfg.Theme)
19442022
_ = config.SaveConfig(m.configFilePath, m.cfg)
1945-
m.banner = fmt.Sprintf("Theme changed to: %s", GetThemeInfo(themeName))
2023+
2024+
// Update profile with new theme
2025+
_ = m.updateProfileTheme(actualThemeName)
2026+
2027+
m.banner = fmt.Sprintf("Theme changed to: %s", GetThemeInfo(actualThemeName))
2028+
2029+
// Redraw viewport and user list with new theme
2030+
m.viewport.SetContent(renderMessages(m.messages, m.styles, m.cfg.Username, m.users, m.viewport.Width, m.twentyFourHour))
2031+
m.userListViewport.SetContent(renderUserList(m.users, m.cfg.Username, m.styles, m.width, *isAdmin, m.selectedUserIndex))
19462032
}
19472033
} else {
19482034
m.banner = "Please provide a theme name. Use :themes to list available themes."
@@ -1957,6 +2043,11 @@ func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
19572043
m.textarea.SetValue("")
19582044
return m, nil
19592045
}
2046+
if text == ":q" {
2047+
m.closeWebSocket()
2048+
m.textarea.SetValue("")
2049+
return m, tea.Quit
2050+
}
19602051
// Individual E2E encryption commands removed - only global E2E encryption supported
19612052
if text == ":time" {
19622053
m.twentyFourHour = !m.twentyFourHour
@@ -2183,7 +2274,7 @@ func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
21832274
if m.conn != nil {
21842275
// Check if this is a server-side command (admin/plugin) that should bypass encryption
21852276
// Client-side commands are handled above and never reach this point
2186-
clientOnlyCommands := []string{":theme", ":time", ":clear", ":bell", ":bell-mention", ":code", ":sendfile", ":savefile"}
2277+
clientOnlyCommands := []string{":theme", ":time", ":clear", ":bell", ":bell-mention", ":code", ":sendfile", ":savefile", ":q"}
21872278
isClientCommand := false
21882279
for _, cmd := range clientOnlyCommands {
21892280
// Check if text is exactly the command or starts with "command "
@@ -3346,6 +3437,7 @@ func initializeClient(cfg *config.Config, adminKeyParam, keystorePassphraseParam
33463437
m := &model{
33473438
cfg: *cfg,
33483439
configFilePath: configFilePath,
3440+
profileName: getCurrentProfileName(cfg),
33493441
textarea: ta,
33503442
viewport: vp,
33513443
styles: getThemeStyles(cfg.Theme),

client/theme_loader.go

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import (
55
"fmt"
66
"os"
77
"path/filepath"
8+
"strings"
89

910
"github.com/charmbracelet/lipgloss"
1011
)
@@ -131,6 +132,32 @@ func GetCustomTheme(themeName string) (ThemeDefinition, bool) {
131132
return theme, exists
132133
}
133134

135+
// GetCustomThemeByName performs case-insensitive lookup of custom themes by display name
136+
func GetCustomThemeByName(displayName string) (string, ThemeDefinition, bool) {
137+
displayNameLower := strings.ToLower(displayName)
138+
139+
// First try exact key match
140+
if theme, exists := customThemes[displayNameLower]; exists {
141+
return displayNameLower, theme, true
142+
}
143+
144+
// Then try case-insensitive key match
145+
for key, theme := range customThemes {
146+
if strings.ToLower(key) == displayNameLower {
147+
return key, theme, true
148+
}
149+
}
150+
151+
// Finally try case-insensitive display name match
152+
for key, theme := range customThemes {
153+
if strings.ToLower(theme.Name) == displayNameLower {
154+
return key, theme, true
155+
}
156+
}
157+
158+
return "", ThemeDefinition{}, false
159+
}
160+
134161
// ListAllThemes returns all available themes (built-in + custom)
135162
func ListAllThemes() []string {
136163
builtIn := []string{"system", "patriot", "retro", "modern"}

0 commit comments

Comments
 (0)