Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
54 commits
Select commit Hold shift + click to select a range
dc0b091
feat(i18n): implement internationalization support with language mana…
sthbryan May 8, 2026
3e2e4bf
feat(i18n): add core i18n package with EN/ES locales
sthbryan May 8, 2026
ca0f3d5
feat(app): init i18n in New() and add Language to Config
sthbryan May 8, 2026
87dd475
feat(ui): add Left/Right keys for language selector
sthbryan May 8, 2026
5f83f9f
feat(settings): implement language selector with ←/→ and Enter
sthbryan May 8, 2026
833c540
feat(views): add i18n to all TUI views and components
sthbryan May 8, 2026
d91a0ef
feat(logs): add i18n to tunnel logs view
sthbryan May 8, 2026
bfaced5
feat(download): add i18n to downloading view
sthbryan May 8, 2026
67f51ef
feat(list): i18n for success prefix and dashboard label
sthbryan May 8, 2026
b2602e7
fix(detail): add i18n for URL and Error labels
sthbryan May 8, 2026
824a5bc
fix(list): i18n for select tunnel details placeholder
sthbryan May 8, 2026
3e0087e
feat(i18n): expose translations API for web frontend
sthbryan May 8, 2026
52b385e
feat(i18n): add i18n store for Svelte frontend
sthbryan May 8, 2026
2937722
fix(build): add bun install before build
sthbryan May 8, 2026
b9cc7f8
feat(web): add language selector in settings with i18n
sthbryan May 8, 2026
5e23464
fix(main): remove unnecessary embed directive for assets
sthbryan May 8, 2026
a4047d9
fix(api): fix Settings type export for Svelte build
sthbryan May 8, 2026
a3661b1
fix(i18n): return translations as flat object, fix lang param
sthbryan May 8, 2026
42916af
fix(ThemeButton): add cursor pointer to button class for better UX
sthbryan May 8, 2026
3e3862a
feat(Header): integrate i18n for app name, tagline, and settings label
sthbryan May 8, 2026
c385085
feat(i18n): add i18n in NewConnection
sthbryan May 8, 2026
4c7fe2e
feat(i18n): add translations to NewConnection and ConnectionsPanel
sthbryan May 8, 2026
82f529d
feat(i18n): add TunnelCard translations
sthbryan May 8, 2026
765aaae
refactor(i18n): add missing status keys
sthbryan May 8, 2026
d05d1cc
feat(i18n): add DeleteModal translations
sthbryan May 9, 2026
edfb300
feat(i18n): add i18n to EditConnection toasts
sthbryan May 9, 2026
40056b5
feat(i18n): add i18n to EditConnection component
sthbryan May 9, 2026
17f6057
feat(i18n): add i18n to NotificationPermission
sthbryan May 9, 2026
d161e4f
feat(i18n): add i18n to settings page
sthbryan May 10, 2026
4844ac5
feat(i18n): add current_theme to ThemeSelector
sthbryan May 10, 2026
946171c
feat(i18n): add i18n to Dropdown defaults
sthbryan May 10, 2026
e4ee5ae
feat(i18n): add i18n to SettingsToggle and Toasts
sthbryan May 10, 2026
b81fa42
feat(i18n): add TF() and TUI locale keys
sthbryan May 10, 2026
578dc55
feat(i18n): use i18n.T in model_types
sthbryan May 10, 2026
840f797
feat(i18n): use i18n.T in view.go
sthbryan May 10, 2026
f23e4ce
feat(i18n): add i18n to model and update
sthbryan May 10, 2026
31d7cb6
feat(i18n): add i18n to form.go
sthbryan May 10, 2026
6723dad
feat(i18n): add i18n to keyhandlers
sthbryan May 10, 2026
a212061
feat(i18n): add i18n to tunnel_item
sthbryan May 10, 2026
13c5a1a
feat(i18n): add i18n to help_bar
sthbryan May 10, 2026
d8e9e90
feat(i18n): add i18n to list.go
sthbryan May 10, 2026
fc36cd0
feat(i18n): add i18n to main.go
sthbryan May 10, 2026
64877a0
feat(i18n): update formatting in meta function for tunnel item
sthbryan May 10, 2026
239f053
fix(i18n): use i18n.T for port label
sthbryan May 10, 2026
13dec2d
fix: remove duplicated key
sthbryan May 10, 2026
4f19002
refacot: add embed directive for frontend assets
sthbryan May 10, 2026
50c9eaf
fix(i18n): fix TF placeholder format
sthbryan May 10, 2026
41e9aa8
fix(i18n): use TF for download/install step
sthbryan May 11, 2026
7ca4189
fix(build): use frozen lockfile for bun install
sthbryan May 11, 2026
21359b6
fix(i18n): update downloading/installing keys
sthbryan May 11, 2026
b4be063
fix(i18n): add missing status_* keys
sthbryan May 11, 2026
108fc24
fix(i18n): add mutex for currentLang
sthbryan May 11, 2026
36775b9
fix(web): validate language before saving
sthbryan May 11, 2026
c8d0172
fix(i18n): remove redundant downloading key in Spanish locale
sthbryan May 11, 2026
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
19 changes: 10 additions & 9 deletions cmd/ftm/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import (
"path/filepath"

"github.com/sthbryan/ftm/internal/app"
"github.com/sthbryan/ftm/internal/i18n"
"github.com/sthbryan/ftm/internal/version"
)

Expand All @@ -16,7 +17,7 @@ var BuildVersion string
func doUninstall() {
binaryPath, err := exec.LookPath("ftm")
if err != nil {
fmt.Println("ftm is not installed or not in PATH")
fmt.Println(i18n.T("uninstall_not_found"))
os.Exit(1)
}

Expand All @@ -25,13 +26,13 @@ func doUninstall() {
absPath = binaryPath
}

fmt.Printf("Removing %s...\n", absPath)
fmt.Println(i18n.TF("uninstall_removing", absPath))
if err := os.Remove(absPath); err != nil {
fmt.Fprintf(os.Stderr, "Error removing binary: %v\n", err)
fmt.Fprintf(os.Stderr, i18n.TF("uninstall_error", err.Error())+"\n")
os.Exit(1)
}

fmt.Println("✓ ftm has been uninstalled")
fmt.Println(i18n.T("uninstall_success"))
}

func main() {
Expand All @@ -45,7 +46,7 @@ func main() {
flag.Parse()

if *showVersion {
fmt.Printf("Foundry Tunnel Manager v%s\n", version.Version)
fmt.Println(i18n.TF("version_output", version.Version))
os.Exit(0)
}

Expand Down Expand Up @@ -75,18 +76,18 @@ func main() {

url := application.WebServer.URL()
fmt.Printf("🎲 Foundry Tunnel Manager v%s\n", BuildVersion)
fmt.Printf("🌐 Dashboard running at: %s\n", url)
fmt.Printf(i18n.TF("dashboard_url", url))

if *webOnly {
fmt.Println("\nPress Ctrl+C to stop")
fmt.Print(i18n.T("press_ctrl_c"))
application.OpenDashboard()
select {}
} else if *server {
fmt.Println("\nPress Ctrl+C to stop")
fmt.Print(i18n.T("press_ctrl_c"))
select {}
}

fmt.Printf("\nPress 'w' in the TUI to open the dashboard\n\n")
fmt.Print(i18n.T("tui_hint"))

if err := application.Run(); err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
Expand Down
14 changes: 11 additions & 3 deletions internal/app/app.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import (
tea "github.com/charmbracelet/bubbletea"

"github.com/sthbryan/ftm/internal/config"
"github.com/sthbryan/ftm/internal/i18n"
"github.com/sthbryan/ftm/internal/notifications"
"github.com/sthbryan/ftm/internal/process"
"github.com/sthbryan/ftm/internal/providers"
Expand All @@ -18,16 +19,23 @@ type App struct {
Config *config.Config
Manager *process.Manager
WebServer *web.Server
DownloadProgress chan providers.DownloadProgress
DownloadProgress chan providers.DownloadProgress
ExpirationMonitor *notifications.ExpirationMonitor
}

func New() (*App, error) {

if err := i18n.Load(); err != nil {
return nil, fmt.Errorf("failed to load translations: %w", err)
}

cfg, err := config.Load()
if err != nil {
return nil, fmt.Errorf("failed to load config: %w", err)
}

i18n.InitFromConfig(cfg)

manager := process.NewManager()

app := &App{
Expand All @@ -42,8 +50,8 @@ func New() (*App, error) {
notifications.SetNotificationsEnabled(cfg.NotificationsStatus == config.NotificationGranted)

expConfig := notifications.ExpirationConfig{
Thresholds: cfg.ExpirationThresholds,
ProviderExpirationMinutes: cfg.ProviderExpirationMinutes,
Thresholds: cfg.ExpirationThresholds,
ProviderExpirationMinutes: cfg.ProviderExpirationMinutes,
}
app.ExpirationMonitor = notifications.NewExpirationMonitor(expConfig, func(name string, mins int) {
if !app.shouldUseNativeNotifications() {
Expand Down
7 changes: 4 additions & 3 deletions internal/app/form.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import (
tea "github.com/charmbracelet/bubbletea"

"github.com/sthbryan/ftm/internal/config"
"github.com/sthbryan/ftm/internal/i18n"
)

func (m *Model) handleFormKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
Expand Down Expand Up @@ -111,7 +112,7 @@ func (m *Model) handlePortInput(s string) {

func (m *Model) submitForm() {
if m.FormValues.Name == "" || m.FormValues.Port == "" {
m.showMessage("Name and Port are required")
m.showMessage(i18n.T("validation_required_fields"))
return
}

Expand Down Expand Up @@ -139,7 +140,7 @@ func (m *Model) submitEditForm() {
m.App.SaveConfig()
m.refreshItems()
m.State = viewList
m.showMessage("Tunnel updated!")
m.showMessage(i18n.T("tunnel_updated"))
}

func (m *Model) submitAddForm() {
Expand All @@ -156,7 +157,7 @@ func (m *Model) submitAddForm() {
m.App.SaveConfig()
m.refreshItems()
m.State = viewList
m.showMessage("Tunnel added!")
m.showMessage(i18n.T("tunnel_added"))
}

func parsePort(s string) int {
Expand Down
17 changes: 9 additions & 8 deletions internal/app/keyhandlers_list.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import (

"github.com/sthbryan/ftm/internal/clipboard"
"github.com/sthbryan/ftm/internal/config"
"github.com/sthbryan/ftm/internal/i18n"
)

func (m *Model) handleListKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
Expand Down Expand Up @@ -102,7 +103,7 @@ func (m *Model) startAddForm() {
func (m *Model) startEditForm() (tea.Model, tea.Cmd) {
if item, ok := m.selectedItem(); ok {
if item.Status.State != config.TunnelStateStopped {
m.showMessage("Stop tunnel first to edit")
m.showMessage(i18n.T("error_tunnel_running"))
return m, nil
}
m.State = viewEditForm
Expand All @@ -127,34 +128,34 @@ func (m *Model) handleListDelete() (tea.Model, tea.Cmd) {
if m.Cursor >= len(m.Items) && m.Cursor > 0 {
m.Cursor--
}
m.showMessage("Tunnel deleted")
m.showMessage(i18n.T("tunnel_deleted"))
}
return m, nil
}

func (m *Model) copyTunnelURL(item TunnelItem) {
if item.Status.PublicURL != "" {
clipboard.Write(item.Status.PublicURL)
m.showMessage("Copied URL!")
m.showMessage(i18n.T("url_copied"))
return
}
m.showMessage("No URL available - start tunnel first")
m.showMessage(i18n.T("error_no_url"))
}

func (m *Model) openDashboard() {
if err := m.App.OpenDashboard(); err != nil {
m.showMessage("Error opening dashboard: " + err.Error())
m.showMessage(i18n.TF("error_dashboard", err.Error()))
return
}
m.showMessage("Dashboard opened in browser")
m.showMessage(i18n.T("dashboard_opened"))
}

func (m *Model) openConfigDir() {
if err := m.App.OpenConfigDir(); err != nil {
m.showMessage("Error opening config folder: " + err.Error())
m.showMessage(i18n.TF("error_config", err.Error()))
return
}
m.showMessage("Config folder opened")
m.showMessage(i18n.T("config_opened"))
}

func (m *Model) playBeep() {
Expand Down
39 changes: 38 additions & 1 deletion internal/app/keyhandlers_settings.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import (

"github.com/sthbryan/ftm/internal/app/ui/views"
"github.com/sthbryan/ftm/internal/config"
"github.com/sthbryan/ftm/internal/i18n"
)

func (m *Model) handleSettingsKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
Expand All @@ -23,10 +24,22 @@ func (m *Model) handleSettingsKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
}

case key.Matches(msg, m.Keys.Down):
if sv.Focused < 1 {
if sv.Focused < 2 {
sv.Focused++
}

case key.Matches(msg, m.Keys.Left):
if sv.Focused == 2 {
sv.Language = cycleLanguage(sv.Language, -1)
i18n.SetLanguage(sv.Language)
}

case key.Matches(msg, m.Keys.Right):
if sv.Focused == 2 {
sv.Language = cycleLanguage(sv.Language, 1)
i18n.SetLanguage(sv.Language)
}

case key.Matches(msg, m.Keys.Enter), key.Matches(msg, m.Keys.Toggle):
m.handleSettingsSelect()

Expand All @@ -51,13 +64,34 @@ func (m *Model) handleSettingsSelect() {
sv.NotificationsEnabled = !sv.NotificationsEnabled
case 1:
sv.NotificationSound = !sv.NotificationSound
case 2:

sv.Language = cycleLanguage(sv.Language, 1)
i18n.SetLanguage(sv.Language)
}
}

func cycleLanguage(current string, dir int) string {
langs := i18n.SupportedLanguages()
for i, lang := range langs {
if lang == current {
next := i + dir
if next < 0 {
next = len(langs) - 1
} else if next >= len(langs) {
next = 0
}
return langs[next]
}
}
return langs[0]
}

func (m *Model) openSettings() {
m.SettingsView = views.NewSettingsView()
m.SettingsView.NotificationsEnabled = m.App.Config.NotificationsStatus == config.NotificationGranted
m.SettingsView.NotificationSound = m.App.Config.NotificationSound
m.SettingsView.Language = i18n.GetCurrentLang()
m.State = viewSettings
}

Expand All @@ -75,6 +109,9 @@ func (m *Model) saveSettings() {
}

m.App.Config.NotificationSound = sv.NotificationSound
m.App.Config.Language = sv.Language

i18n.SetLanguage(sv.Language)

m.App.SaveConfig()
}
3 changes: 2 additions & 1 deletion internal/app/model.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import (
tea "github.com/charmbracelet/bubbletea"

"github.com/sthbryan/ftm/internal/config"
"github.com/sthbryan/ftm/internal/i18n"
"github.com/sthbryan/ftm/internal/providers"
)

Expand Down Expand Up @@ -67,7 +68,7 @@ func (m *Model) installProvider(providerType config.Provider) tea.Cmd {
if err != nil {
return statusUpdateMsg{
tunnelID: "",
status: config.TunnelStatus{ErrorMessage: "Install failed: " + err.Error(), State: config.TunnelStateError},
status: config.TunnelStatus{ErrorMessage: i18n.TF("error_install_failed", err.Error()), State: config.TunnelStateError},
}
}
return downloadProgressMsg{Done: true, Percent: 100}
Expand Down
25 changes: 20 additions & 5 deletions internal/app/model_types.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import (

"github.com/sthbryan/ftm/internal/app/ui/views"
"github.com/sthbryan/ftm/internal/config"
"github.com/sthbryan/ftm/internal/i18n"
"github.com/sthbryan/ftm/internal/providers"
)

Expand All @@ -37,6 +38,8 @@ const TwoColumnThreshold = 100
type KeyMap struct {
Up key.Binding
Down key.Binding
Left key.Binding
Right key.Binding
Enter key.Binding
Toggle key.Binding
Logs key.Binding
Expand All @@ -61,6 +64,14 @@ var DefaultKeys = KeyMap{
key.WithKeys("down", "j"),
key.WithHelp("↓/j", "down"),
),
Left: key.NewBinding(
key.WithKeys("left", "h"),
key.WithHelp("←/h", "prev"),
),
Right: key.NewBinding(
key.WithKeys("right", "l"),
key.WithHelp("→/l", "next"),
),
Enter: key.NewBinding(
key.WithKeys("enter"),
key.WithHelp("enter", "toggle"),
Expand Down Expand Up @@ -155,14 +166,18 @@ func (i TunnelItem) FilterValue() string { return i.Tunnel.Name }
func (i TunnelItem) Title() string { return i.Tunnel.Name }

func (i TunnelItem) Description() string {
status := "OFFLINE"
status := i18n.T("status_offline")
switch i.Status.State {
case config.TunnelStateStarting:
status = "STARTING"
status = i18n.T("status_starting")
case config.TunnelStateConnecting:
status = "CONNECTING"
status = i18n.T("status_connecting")
case config.TunnelStateOnline:
status = "ONLINE"
status = i18n.T("status_online")
case config.TunnelStateError:
status = i18n.T("status_error")
case config.TunnelStateTimeout:
status = i18n.T("status_timeout")
}
return fmt.Sprintf("%s | Port %d | %s", i.Tunnel.Provider, i.Tunnel.LocalPort, status)
return fmt.Sprintf("%s | %s %d | %s", i.Tunnel.Provider, i18n.T("port"), i.Tunnel.LocalPort, status)
}
Comment thread
sthbryan marked this conversation as resolved.
Loading
Loading