diff --git a/kittens/remote_file/ssh.go b/kittens/remote_file/ssh.go index 47da5b937..2200d740d 100644 --- a/kittens/remote_file/ssh.go +++ b/kittens/remote_file/ssh.go @@ -99,23 +99,43 @@ func hostname_matches(from_hyperlink, actual string) bool { return fl == al } -// pythonSplitExt mirrors Python's os.path.splitext behavior for dotfiles. -// For a file like "/x/.bashrc" (basename ".bashrc"), Python returns ("/x/.bashrc", ""). -// Go's filepath.Ext would return (".bashrc", "/x/"), losing the filename. -// Rule: if basename starts with '.' and has no other dots, treat extension as empty. +// pythonSplitExt mirrors Python's os.path.splitext behavior exactly. +// Algorithm: skip all leading dots in basename; find last dot in remainder; +// if found, split at that dot; otherwise no extension. +// Examples: +// "/x/.bashrc" → ("/x/.bashrc", "") [1 leading dot, no dot in remainder] +// "/x/.bashrc.bak" → ("/x/.bashrc", ".bak") [1 leading dot, 1 dot in remainder] +// "/x/a.b.c" → ("/x/a.b", ".c") [0 leading dots, last dot at position 3] +// "....txt" → ("....txt", "") [4 leading dots, no dot in remainder] func pythonSplitExt(path string) (string, string) { - ext := filepath.Ext(path) - if ext == "" { - return path, "" - } basename := filepath.Base(path) - // If basename starts with '.' and has exactly one dot (the leading dot), - // then this is a dotfile with no extension. - if strings.HasPrefix(basename, ".") && strings.Count(basename, ".") == 1 { + dir := filepath.Dir(path) + + // Skip all leading dots in basename + i := 0 + for i < len(basename) && basename[i] == '.' { + i++ + } + + // Find the last dot in the remainder (after leading dots) + remainder := basename[i:] + idx := strings.LastIndex(remainder, ".") + + if idx == -1 { + // No extension found return path, "" } - base := strings.TrimSuffix(path, ext) - return base, ext + + // Extension starts at position i + idx in the original basename + extStart := i + idx + baseName := basename[:extStart] + ext := basename[extStart:] + + // Reconstruct the full path + if dir == "." { + return baseName, ext + } + return filepath.Join(dir, baseName), ext } func auto_rename_dest(dest string, exists func(string) bool) string { diff --git a/kittens/remote_file/ssh_test.go b/kittens/remote_file/ssh_test.go index 461792538..eb845a23e 100644 --- a/kittens/remote_file/ssh_test.go +++ b/kittens/remote_file/ssh_test.go @@ -95,3 +95,35 @@ func TestParseConnDataSSHKittenEdge(t *testing.T) { t.Fatalf("ssh kitten edge case (single item): %+v, SSHKittenCmdline=%v", c, c.SSHKittenCmdline) } } + +func TestPythonSplitExt(t *testing.T) { + // Table-driven test for pythonSplitExt matching os.path.splitext semantics. + // Expected values from Python 3: python3 -c "import os; print(os.path.splitext(f))" for each. + // Reference run: 2026-08-12 + for _, tc := range []struct { + input string + wantBase string + wantExt string + }{ + // Dotfiles with no extension in remainder (leading dots only) + {".bashrc", ".bashrc", ""}, + {"..bashrc", "..bashrc", ""}, + {"...", "...", ""}, + {"..", "..", ""}, + {"....txt", "....txt", ""}, // 4 leading dots, then "txt" with no dot + + // Dotfiles with extension in remainder + {".bashrc.bak", ".bashrc", ".bak"}, + + // Normal files + {"a.b.c", "a.b", ".c"}, + {"a.txt", "a", ".txt"}, + {"plain", "plain", ""}, + } { + base, ext := pythonSplitExt(tc.input) + if base != tc.wantBase || ext != tc.wantExt { + t.Fatalf("pythonSplitExt(%q):\n got (%q, %q)\n want (%q, %q)", + tc.input, base, ext, tc.wantBase, tc.wantExt) + } + } +}