Skip to content
Merged
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
1 change: 1 addition & 0 deletions .envrc.example
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
PATH_add support/showroom
4 changes: 1 addition & 3 deletions .goreleaser.yml
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,4 @@ changelog:
- "^- "

release:
prerelease: true
header: |
Commando is a work in progress. This is an early Windows preview.
prerelease: false
65 changes: 57 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,15 +1,64 @@
# Commando
<div align="center">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="support/readme/dark/logo.svg">
<source media="(prefers-color-scheme: light)" srcset="support/readme/light/logo.svg">
<img src="support/readme/light/logo.svg" alt="Commando" width="390" height="157">
</picture>

**Work in progress.** Commando is an early preview; features and behavior may change.
<h1>Commando</h1>

![repocard](https://repocard.dannyben.com/svg/commando.svg)
<p><strong>The file terminal for power users with mouse issues.</strong></p>
<p>Commando is a multi-panel file manager for Windows, with obsessive keyboard support, shortcuts you can actually discover, and a workspace you can make your own.</p>
<p><strong><a href="https://github.com/DannyBen/commando/releases">Download Commando for Windows</a></strong></p>
<hr>
</div>

A keyboard-first multi-panel file terminal for Windows.
<p align="center">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="support/readme/dark/overview.png">
<source media="(prefers-color-scheme: light)" srcset="support/readme/light/overview.png">
<img src="support/readme/light/overview.png" alt="Three Commando panels with marked downloads and a mid-list selection" width="960">
</picture>
</p>

## Download
## Multi-panel. Because two is never enough.

[Download from GitHub Releases](https://github.com/DannyBen/commando/releases).
<p align="center">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="support/readme/dark/panels.gif">
<source media="(prefers-color-scheme: light)" srcset="support/readme/light/panels.gif">
<img src="support/readme/light/panels.gif" alt="Expanding panels, filtering media files, and retaining a marked batch through panel changes" width="960">
</picture>
</p>

## Usage
Open several folders side by side, each with its own selection, marks, and view.
Dense file lists and compact metadata keep the focus on files, with no icons or
tile views.

Press Backtick or Ctrl+Shift+P to open the command palette and view key bindings.
## Themes. More themes!

<p align="center">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="support/readme/dark/themes.gif">
<source media="(prefers-color-scheme: light)" srcset="support/readme/light/themes.gif">
<img src="support/readme/light/themes.gif" alt="Ten live theme palettes, progressing through seeds, vibrance, dynamics, and color rotation" width="960">
</picture>
</p>

Generate new palettes effortlessly, from quiet neutrals to unapologetic color.
Tune the result live, then make the rest yours with fonts, spacing, and file-type
colors.

## Keyboard first. Mouse: strictly optional.

<p align="center">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="support/readme/dark/commands.gif">
<source media="(prefers-color-scheme: light)" srcset="support/readme/light/commands.gif">
<img src="support/readme/light/commands.gif" alt="Touring the command catalogue, searching for Rename, inspecting its shortcuts, and executing it" width="960">
</picture>
</p>

Navigation, selection, filtering, and file operations all work from the keyboard.
The command palette puts actions, settings, and shortcut customization in one
searchable place—part command launcher, part help, all within reach.
Binary file modified assets/app-icon/app-icon.ico
Binary file not shown.
13 changes: 7 additions & 6 deletions assets/app-icon/app-icon.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
7 changes: 6 additions & 1 deletion internal/app/adjacent_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -167,8 +167,13 @@ func TestCtrlArrowsStayInLocationEditor(t *testing.T) {
send(key.Event{Name: "D", Modifiers: key.ModAlt, State: key.Press})
for _, direction := range []int{-1, 1} {
send(adjacentKey(direction))
if !v.locationEditing() || v.active != 0 || len(v.loader.requests) != 0 {
if !v.locationEditing() || v.active != 0 || v.panels[0].loading || v.panels[1].loading {
t.Fatal("Ctrl+arrow in the editor opened a folder or switched panels")
}
for len(v.loader.requests) > 0 {
if request := <-v.loader.requests; request.completion == nil {
t.Fatal("editor queued navigation")
}
}
}
}
4 changes: 4 additions & 0 deletions internal/app/command_actions.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,10 @@ func (v *view) executeBuiltinCommand(gtx layout.Context, id string) {
v.duplicateSelected()
case "entry.copy_to_panel", "entry.move_to_panel":
v.startPanelTransfer(id == "entry.move_to_panel")
case "entry.copy_left", "entry.move_left":
v.transferToNeighbor(-1, id == "entry.move_left")
case "entry.copy_right", "entry.move_right":
v.transferToNeighbor(1, id == "entry.move_right")
case "entry.recycle":
v.recycleSelected()
case "entry.new_file":
Expand Down
4 changes: 4 additions & 0 deletions internal/app/commands.go
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,10 @@ func defaultCommands() []command {
cmd("entry.duplicate", "Entry", "Duplicate", "D", key.ModCtrl, selectedMutationReady),
cmd("entry.copy_to_panel", "Entry", "Copy to panel", key.NameF5, 0, func(v *view) bool { return len(v.panels) > 1 && selectedMutationReady(v) }),
cmd("entry.move_to_panel", "Entry", "Move to panel", key.NameF6, 0, func(v *view) bool { return len(v.panels) > 1 && selectedMutationReady(v) }),
cmd("entry.copy_left", "Entry", "Copy to left", "[", 0, func(v *view) bool { return panelTransferReady(v, -1) }),
cmd("entry.copy_right", "Entry", "Copy to right", "]", 0, func(v *view) bool { return panelTransferReady(v, 1) }),
cmd("entry.move_left", "Entry", "Move to left", "{", 0, func(v *view) bool { return panelTransferReady(v, -1) }),
cmd("entry.move_right", "Entry", "Move to right", "}", 0, func(v *view) bool { return panelTransferReady(v, 1) }),
cmd("entry.recycle", "Entry", "Recycle", key.NameDeleteForward, 0, selectedMutationReady),
cmd("entry.new_file", "Entry", "New file", "N", key.ModCtrl, writableFolder),
cmd("entry.new_folder", "Entry", "New folder", "N", key.ModCtrl|key.ModShift, writableFolder),
Expand Down
20 changes: 18 additions & 2 deletions internal/app/completion.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import (
"gioui.org/layout"
"gioui.org/op"
"gioui.org/op/clip"
"gioui.org/op/paint"
"gioui.org/text"
"gioui.org/widget"
"github.com/dannyben/commando/internal/settings"
Expand All @@ -23,6 +24,7 @@ type inlineCompletion struct {
query string
matches []string
selected int
trailer string // Optional closing delimiter, retained when completing inside quotes.
}

func completionCandidates(names []string) []string {
Expand All @@ -36,7 +38,12 @@ func completionCandidates(names []string) []string {
func (c *inlineCompletion) update(editor *widget.Editor) {
query := editor.Text()
start, end := editor.Selection()
if query == "" || start != end || end != utf8.RuneCountInString(query) {
limit := utf8.RuneCountInString(query)
if c.trailer != "" && strings.HasSuffix(query, c.trailer) {
query = strings.TrimSuffix(query, c.trailer)
limit -= utf8.RuneCountInString(c.trailer)
}
if query == "" || start != end || end != limit {
query = ""
}
if query != c.query {
Expand All @@ -48,7 +55,7 @@ func (c *inlineCompletion) update(editor *widget.Editor) {
}
n := utf8.RuneCountInString(query)
for _, name := range c.candidates {
if strings.EqualFold(name, query) {
if strings.EqualFold(strings.TrimSuffix(name, c.trailer), query) {
// A complete name stays literal, even if longer names share it.
c.matches = c.matches[:0]
return
Expand Down Expand Up @@ -87,6 +94,10 @@ func (c *inlineCompletion) accept(editor *widget.Editor) bool {
name := c.matches[c.selected]
editor.SetCaret(0, utf8.RuneCountInString(editor.Text()))
editor.Insert(name)
if c.trailer != "" && strings.HasSuffix(name, c.trailer) {
end := utf8.RuneCountInString(strings.TrimSuffix(name, c.trailer))
editor.SetCaret(end, end)
}
c.update(editor)
return true
}
Expand All @@ -105,6 +116,11 @@ func (v *view) completionEditor(gtx layout.Context, editor *widget.Editor, c *in
call := macro.Stop()
caret := editor.CaretCoords()
at := image.Pt(int(math.Round(float64(caret.X))), int(math.Round(float64(caret.Y)))-label.Size.Y+label.Baseline)
if c.trailer != "" && strings.HasSuffix(editor.Text(), c.trailer) {
// The preview replaces the closing delimiter visually; the editor's
// text and selection stay untouched until explicit acceptance.
paint.FillShape(gtx.Ops, v.currentTextBackground(), clip.Rect{Min: at, Max: at.Add(image.Pt(v.textWidth(gtx, c.trailer, v.appearance.TextSize), label.Size.Y))}.Op())
}
defer op.Offset(at).Push(gtx.Ops).Pop()
call.Add(gtx.Ops)
}
Expand Down
2 changes: 1 addition & 1 deletion internal/app/dimmed_file_contrast_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,7 @@ func TestDimmedFileRendererUsesSameRoleForNameAndMetadata(t *testing.T) {
var ops op.Ops
gtx := layout.Context{Ops: &ops, Metric: unit.Metric{PxPerDp: 1, PxPerSp: 1}, Constraints: layout.Exact(image.Pt(400, 40))}
v.fileRowSurface(gtx, gtx.Constraints.Max, bg, color.NRGBA{}, func(gtx layout.Context) layout.Dimensions {
return v.rowContents(gtx, entry{name: file.Name, size: "1 KB", file: file}, name, metadata, mode, v.appearance.entryTextRoles(file, false, false))
return v.rowContents(gtx, entry{name: file.Name, size: "1 KB", file: file}, name, metadata, mode, v.appearance.entryTextRoles(file, false, false), false)
})
dimKey := textContrastPair{name, bg, themeContrastRules[contrastDimmedFile], color.NRGBA{}}
if _, ok := v.textContrast[dimKey]; !ok {
Expand Down
6 changes: 4 additions & 2 deletions internal/app/file_clipboard_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -278,8 +278,10 @@ func TestFileClipboardShortcutsSkipBusyDrivesAndEditors(t *testing.T) {
} else {
send(modified("C", key.ModCtrl), modified("X", key.ModCtrl), modified("D", key.ModCtrl))
}
if len(v.loader.requests) != 0 {
t.Fatal("ignored/editor clipboard shortcut queued file operation")
for len(v.loader.requests) > 0 {
if request := <-v.loader.requests; request.completion == nil {
t.Fatal("ignored/editor clipboard shortcut queued file operation")
}
}
})
}
Expand Down
11 changes: 6 additions & 5 deletions internal/app/file_list.go
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,8 @@ func (v *view) panel(gtx layout.Context, index int) layout.Dimensions {
fg = v.appearance.Colors.ActiveLocation
}
if v.locationEditing() && index == v.active {
return v.textEditor(&v.location, "Path").Layout(gtx)
s := v.interaction.current.(*locationSession)
return v.completionEditor(gtx, &v.location, &s.completion, "Path")
}
return v.label(gtx, fmt.Sprintf("%d %s", index+1, p.locationLabel()), v.appearance.TextSize, fg, text.Start)
}, index == v.active)
Expand All @@ -39,7 +40,7 @@ func (v *view) panel(gtx layout.Context, index int) layout.Dimensions {
if p.filter != "" || v.filterInteraction() != nil && v.filterInteraction().panel == index {
return v.filterContents(gtx, index)
}
return v.columns(gtx, entry{name: "NAME", size: p.metadata.heading()}, v.appearance.Colors.Filename, v.appearance.Colors.Metadata, v.appearance.SmallTextSize, contrastSecondary)
return v.columns(gtx, entry{name: "NAME", size: p.metadata.heading()}, v.appearance.Colors.Filename, v.appearance.Colors.Metadata, v.appearance.SmallTextSize, contrastSecondary, false)
}, false)
}),
layout.Flexed(1, func(gtx layout.Context) layout.Dimensions {
Expand Down Expand Up @@ -117,7 +118,7 @@ func (v *view) panel(gtx layout.Context, index int) layout.Dimensions {
}
return v.nameContents(gtx, metadata, sizeColor)
}
return v.rowContents(gtx, p.items[storedRow], nameColor, sizeColor, p.metadata, v.appearance.entryTextRoles(p.items[storedRow].file, active, cut))
return v.rowContents(gtx, p.items[storedRow], nameColor, sizeColor, p.metadata, v.appearance.entryTextRoles(p.items[storedRow].file, active, cut), p.drives)
})
}
if naming {
Expand Down Expand Up @@ -166,12 +167,12 @@ func (v *view) fileRowSurface(gtx layout.Context, size image.Point, background,
return v.textOn(background, content)(gtx)
}

func (v *view) rowContents(gtx layout.Context, item entry, nameColor, sizeColor color.NRGBA, mode metadataColumn, roles fileTextRoles) layout.Dimensions {
func (v *view) rowContents(gtx layout.Context, item entry, nameColor, sizeColor color.NRGBA, mode metadataColumn, roles fileTextRoles, drives bool) layout.Dimensions {
return v.textAs(roles.filename, func(gtx layout.Context) layout.Dimensions {
return v.contentInset().Layout(gtx, func(gtx layout.Context) layout.Dimensions {
return layout.W.Layout(gtx, func(gtx layout.Context) layout.Dimensions {
gtx.Constraints.Min.Y = 0
return v.columns(gtx, entry{name: mode.displayName(item), size: mode.value(item)}, nameColor, sizeColor, v.appearance.TextSize, roles.metadata)
return v.columns(gtx, entry{name: mode.displayName(item), size: mode.value(item)}, nameColor, sizeColor, v.appearance.TextSize, roles.metadata, item.file.Directory && !item.file.Parent && !drives)
})
})
})(gtx)
Expand Down
4 changes: 2 additions & 2 deletions internal/app/font.go
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,7 @@ func (v *view) applyFontSettings() {
}
v.appearance.TextSize = unit.Sp(v.settingsValues.Get(c, settingFontTextSize).Number)
v.appearance.SmallTextSize = unit.Sp(v.settingsValues.Get(c, settingFontSmallTextSize).Number)
// Gio's zero weight is regular (CSS 400). Keep this as the shared default;
// semantic styles can override it independently in the future.
// Gio's zero weight is regular (CSS 400).
v.appearance.FontWeight = font.Weight(v.settingsValues.Get(c, settingFontWeight).Number - 400)
v.appearance.BoldWeight = font.Weight(v.settingsValues.Get(c, settingFontBoldWeight).Number - 400)
}
19 changes: 11 additions & 8 deletions internal/app/font_settings_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -40,8 +40,8 @@ func TestFontWidgetInputPreviewCancelResetAndPersistence(t *testing.T) {
before := v.appearance
v.startSettingsWidget("appearance.font")
s := v.settingsInteraction()
if s == nil || s.title != "Appearance › Font" || len(s.fields) != 4 {
t.Fatal("font settings did not open as one four-field widget")
if s == nil || s.title != "Appearance › Font" || len(s.fields) != 5 {
t.Fatal("font settings did not open as one five-field widget")
}
send, _ := panelInputFrames(v)
send(pressed(key.NameReturn))
Expand All @@ -53,20 +53,22 @@ func TestFontWidgetInputPreviewCancelResetAndPersistence(t *testing.T) {
v.adjustSetting(settingFontTextSize, 1)
v.adjustSetting(settingFontSmallTextSize, 1)
v.adjustSetting(settingFontWeight, 1)
if v.appearance.TextSize != 16 || v.appearance.SmallTextSize != 13 || v.appearance.FontWeight != font.Bold ||
v.adjustSetting(settingFontBoldWeight, -1)
if v.appearance.TextSize != 16 || v.appearance.SmallTextSize != 13 || v.appearance.FontWeight != font.Bold || v.appearance.BoldWeight != font.Medium ||
v.textEditor(&widget.Editor{}, "").Font.Weight != font.Bold || v.appearance.RowPaddingVertical != before.RowPaddingVertical {
t.Fatal("font settings did not preview together or changed geometry")
}
v.finishSettingsWidget(layout.Context{}, false)
if v.appearance.FontFamily != before.FontFamily || v.appearance.TextSize != before.TextSize ||
v.appearance.SmallTextSize != before.SmallTextSize || v.appearance.FontWeight != before.FontWeight {
v.appearance.SmallTextSize != before.SmallTextSize || v.appearance.FontWeight != before.FontWeight || v.appearance.BoldWeight != before.BoldWeight {
t.Fatal("Cancel did not restore the font")
}

v.startSettingsWidget("appearance.font")
_ = v.setWidgetValue(settingFontFamily, settings.Value{Text: "Arial"})
v.adjustSetting(settingFontTextSize, 2)
v.adjustSetting(settingFontWeight, 1)
v.adjustSetting(settingFontBoldWeight, -1)
dir := t.TempDir()
v.prepareSettingsSave = func(values settings.Values) func() (string, error) {
return func() (string, error) { return "", settings.Save(dir, values) }
Expand All @@ -78,16 +80,16 @@ func TestFontWidgetInputPreviewCancelResetAndPersistence(t *testing.T) {
}
restored := newPreviewView()
restored.restoreSettings(values, nil)
if restored.appearance.FontFamily != "Arial" || restored.appearance.TextSize != 17 || restored.appearance.FontWeight != font.Bold {
if restored.appearance.FontFamily != "Arial" || restored.appearance.TextSize != 17 || restored.appearance.FontWeight != font.Bold || restored.appearance.BoldWeight != font.Medium {
t.Fatal("font settings did not apply on startup")
}
restored.startSettingsWidget("appearance.font")
restored.activateSettingsAction(layout.Context{}, 2)
if restored.appearance.FontFamily != before.FontFamily || restored.appearance.TextSize != before.TextSize || restored.appearance.FontWeight != before.FontWeight {
if restored.appearance.FontFamily != before.FontFamily || restored.appearance.TextSize != before.TextSize || restored.appearance.FontWeight != before.FontWeight || restored.appearance.BoldWeight != before.BoldWeight {
t.Fatal("Reset did not restore bundled defaults")
}
restored.finishSettingsWidget(layout.Context{}, false)
if restored.appearance.FontFamily != "Arial" || restored.appearance.TextSize != 17 || restored.appearance.FontWeight != font.Bold {
if restored.appearance.FontFamily != "Arial" || restored.appearance.TextSize != 17 || restored.appearance.FontWeight != font.Bold || restored.appearance.BoldWeight != font.Medium {
t.Fatal("Cancel did not undo Reset")
}
}
Expand Down Expand Up @@ -119,7 +121,7 @@ func TestUnavailableFontUsesBundledMetricsAndSizeRemainsLive(t *testing.T) {
for _, tc := range []struct {
key string
n float64
}{{settingFontTextSize, 0}, {settingFontSmallTextSize, 100}, {settingFontWeight, 1000}} {
}{{settingFontTextSize, 0}, {settingFontSmallTextSize, 100}, {settingFontWeight, 1000}, {settingFontBoldWeight, 1000}} {
if err := v.setWidgetValue(tc.key, settings.Value{Number: tc.n}); err == nil {
t.Fatalf("invalid %s accepted", tc.key)
}
Expand All @@ -133,6 +135,7 @@ func TestFontWidgetPreview(t *testing.T) {
}
v := newPreviewView()
v.startSettingsWidget("appearance.font")
v.adjustSetting(settingFontBoldWeight, 1)
if err := renderViewPNG(filepath.Join(dir, "settings-font.png"), v); err != nil {
t.Fatal(err)
}
Expand Down
Loading
Loading