diff --git a/src/prompt/engine.go b/src/prompt/engine.go index 392c9b0bd0db..d3ccf18d5afe 100644 --- a/src/prompt/engine.go +++ b/src/prompt/engine.go @@ -171,7 +171,7 @@ func (e *Engine) getTitleTemplateText() string { } func (e *Engine) renderBlock(block *config.Block, cancelNewline bool) bool { - defer e.patchPowerShellBleed() + defer e.applyPowerShellBleedPatch() // This is deprecated but we leave it in to not break configs // It is encouraged to use "newline": true on block level @@ -267,7 +267,7 @@ func (e *Engine) renderBlock(block *config.Block, cancelNewline bool) bool { return true } -func (e *Engine) patchPowerShellBleed() { +func (e *Engine) applyPowerShellBleedPatch() { // when in PowerShell, we need to clear the line after the prompt // to avoid the background being printed on the next line // when at the end of the buffer. @@ -514,10 +514,6 @@ func New(flags *runtime.Flags) *Engine { env.Init() cfg := config.Load(env) - if cfg.PatchPwshBleed { - patchPowerShellBleed(env.Shell(), flags) - } - env.Var = cfg.Var flags.HasTransient = cfg.TransientPrompt != nil @@ -532,10 +528,16 @@ func New(flags *runtime.Flags) *Engine { Plain: flags.Plain, } + if cfg.PatchPwshBleed { + eng.patchPowerShellBleed() + } + return eng } -func patchPowerShellBleed(sh string, flags *runtime.Flags) { +func (e *Engine) patchPowerShellBleed() { + sh := e.Env.Shell() + // when in PowerShell, and force patching the bleed bug // we need to reduce the terminal width by 1 so the last // character isn't cut off by the ANSI escape sequences @@ -544,10 +546,12 @@ func patchPowerShellBleed(sh string, flags *runtime.Flags) { return } - // only do this when relevant - if flags.TerminalWidth <= 0 { + // Since the terminal width may not be given by the CLI flag, we should always call this here. + _, err := e.Env.TerminalWidth() + if err != nil { + // Skip when we're unable to determine the terminal width. return } - flags.TerminalWidth-- + e.Env.Flags().TerminalWidth-- } diff --git a/src/runtime/terminal.go b/src/runtime/terminal.go index 8b7656867814..2c1513a02bd3 100644 --- a/src/runtime/terminal.go +++ b/src/runtime/terminal.go @@ -173,25 +173,20 @@ func (term *Terminal) Pwd() string { if term.cwd != "" { return term.cwd } - correctPath := func(pwd string) string { - if term.GOOS() != WINDOWS { - return pwd - } - // on Windows, and being case sensitive and not consistent and all, this gives silly issues - driveLetter := regex.GetCompiledRegex(`^[a-z]:`) - return driveLetter.ReplaceAllStringFunc(pwd, strings.ToUpper) - } + if term.CmdFlags != nil && term.CmdFlags.PWD != "" { - term.cwd = correctPath(term.CmdFlags.PWD) + term.cwd = CleanPath(term, term.CmdFlags.PWD) term.Debug(term.cwd) return term.cwd } + dir, err := os.Getwd() if err != nil { term.Error(err) return "" } - term.cwd = correctPath(dir) + + term.cwd = CleanPath(term, dir) term.Debug(term.cwd) return term.cwd } @@ -321,7 +316,10 @@ func (term *Terminal) LsDir(path string) []fs.DirEntry { func (term *Terminal) PathSeparator() string { defer term.Trace(time.Now()) - return string(os.PathSeparator) + if term.GOOS() == WINDOWS { + return `\` + } + return "/" } func (term *Terminal) User() string { @@ -882,6 +880,54 @@ func Base(env Environment, path string) string { return path } +func CleanPath(env Environment, path string) string { + if len(path) == 0 { + return path + } + + cleaned := path + separator := env.PathSeparator() + + // The prefix can be empty for a relative path. + var prefix string + if IsPathSeparator(env, cleaned[0]) { + prefix = separator + } + + if env.GOOS() == WINDOWS { + // Normalize (forward) slashes to backslashes on Windows. + cleaned = strings.ReplaceAll(cleaned, "/", `\`) + + // Clean the prefix for a UNC path, if any. + if regex.MatchString(`^\\{2}[^\\]+`, cleaned) { + cleaned = strings.TrimPrefix(cleaned, `\\.\UNC\`) + if len(cleaned) == 0 { + return cleaned + } + prefix = `\\` + } + + // Always use an uppercase drive letter on Windows. + driveLetter := regex.GetCompiledRegex(`^[a-z]:`) + cleaned = driveLetter.ReplaceAllStringFunc(cleaned, strings.ToUpper) + } + + sb := new(strings.Builder) + sb.WriteString(prefix) + + // Clean slashes. + matches := regex.FindAllNamedRegexMatch(fmt.Sprintf(`(?P[^\%s]+)`, separator), cleaned) + n := len(matches) - 1 + for i, m := range matches { + sb.WriteString(m["element"]) + if i != n { + sb.WriteString(separator) + } + } + + return sb.String() +} + func ReplaceTildePrefixWithHomeDir(env Environment, path string) string { if !strings.HasPrefix(path, "~") { return path diff --git a/src/segments/path.go b/src/segments/path.go index fc910d892a6c..e5cf43e6d254 100644 --- a/src/segments/path.go +++ b/src/segments/path.go @@ -13,23 +13,44 @@ import ( "github.com/jandedobbeleer/oh-my-posh/src/template" ) +type Folder struct { + Name string + Display bool + Path string +} + +type Folders []*Folder + +func (f Folders) List() []string { + var list []string + + for _, folder := range f { + list = append(list, folder.Name) + } + + return list +} + type Path struct { props properties.Properties env runtime.Environment - root string - relative string - pwd string - cygPath bool - windowsPath bool - pathSeparator string + pwd string + root string + relative string + folders Folders + // After `setPaths` is called, the above 4 fields should remain unchanged to preserve the original path info. + + cygPath bool + windowsPath bool + pathSeparator string + mappedLocations map[string]string Path string StackCount int Location string Writable bool RootDir bool - Folders Folders } const ( @@ -121,7 +142,7 @@ func (pt *Path) Enabled() bool { func (pt *Path) setPaths() { defer func() { - pt.Folders = pt.splitPath() + pt.folders = pt.splitPath() }() displayCygpath := func() bool { @@ -147,33 +168,33 @@ func (pt *Path) setPaths() { } // ensure a clean path - pt.root, pt.relative = pt.replaceMappedLocations() - - // this is a full replacement of the parent - if len(pt.root) == 0 { - pt.pwd = pt.relative - return - } - - if !strings.HasSuffix(pt.root, pt.pathSeparator) && len(pt.relative) > 0 { - pt.pwd = pt.root + pt.pathSeparator + pt.relative - return - } - - pt.pwd = pt.root + pt.relative + pt.root, pt.relative = pt.replaceMappedLocations(pt.pwd) + pt.pwd = pt.join(pt.root, pt.relative) } func (pt *Path) Parent() string { if len(pt.pwd) == 0 { return "" } - if len(pt.relative) == 0 { - // a root path has no parent + + folders := pt.folders.List() + if len(folders) == 0 { + // No parent. return "" } - base := runtime.Base(pt.env, pt.pwd) - path := pt.replaceFolderSeparators(pt.pwd[:len(pt.pwd)-len(base)]) - return path + + sb := new(strings.Builder) + folderSeparator := pt.getFolderSeparator() + + sb.WriteString(pt.root) + if !pt.endWithSeparator(pt.root) { + sb.WriteString(folderSeparator) + } + for _, folder := range folders[:len(folders)-1] { + sb.WriteString(folder) + sb.WriteString(folderSeparator) + } + return sb.String() } func (pt *Path) Init(props properties.Properties, env runtime.Environment) { @@ -183,12 +204,14 @@ func (pt *Path) Init(props properties.Properties, env runtime.Environment) { func (pt *Path) setStyle() { if len(pt.relative) == 0 { + root := pt.root + // Only append a separator to a non-filesystem PSDrive root or a Windows drive root. - if (len(pt.env.Flags().PSWD) != 0 || pt.windowsPath) && strings.HasSuffix(pt.root, ":") { - pt.root += pt.getFolderSeparator() + if (len(pt.env.Flags().PSWD) != 0 || pt.windowsPath) && strings.HasSuffix(root, ":") { + root += pt.getFolderSeparator() } - pt.Path = pt.colorizePath(pt.root, nil) + pt.Path = pt.colorizePath(root, nil) return } @@ -276,75 +299,77 @@ func (pt *Path) getFolderSeparator() string { } func (pt *Path) getMixedPath() string { + root := pt.root + folders := pt.folders threshold := int(pt.props.GetFloat64(MixedThreshold, 4)) folderIcon := pt.props.GetString(FolderIcon, "..") - if pt.root == pt.pathSeparator { - pt.root = pt.Folders[0].Name - pt.Folders = pt.Folders[1:] + if pt.isRootFS(root) { + root = folders[0].Name + folders = folders[1:] } - var folders []string + var elements []string - for i, n := 0, len(pt.Folders); i < n; i++ { - folder := pt.Folders[i].Name - if len(folder) > threshold && i != n-1 && !pt.Folders[i].Display { - folder = folderIcon + for i, n := 0, len(folders); i < n; i++ { + folderName := folders[i].Name + if len(folderName) > threshold && i != n-1 && !folders[i].Display { + elements = append(elements, folderIcon) + continue } - folders = append(folders, folder) + + elements = append(elements, folderName) } - return pt.colorizePath(pt.root, folders) + return pt.colorizePath(root, elements) } func (pt *Path) getAgnosterPath() string { + root := pt.root + folders := pt.folders folderIcon := pt.props.GetString(FolderIcon, "..") - if pt.root == pt.pathSeparator { - pt.root = pt.Folders[0].Name - pt.Folders = pt.Folders[1:] + if pt.isRootFS(root) { + root = folders[0].Name + folders = folders[1:] } var elements []string - n := len(pt.Folders) - for i := 0; i < n-1; i++ { - name := folderIcon - if pt.Folders[i].Display { - name = pt.Folders[i].Name + for i, n := 0, len(folders); i < n; i++ { + if folders[i].Display || i == n-1 { + elements = append(elements, folders[i].Name) + continue } - elements = append(elements, name) - } - - if len(pt.Folders) > 0 { - elements = append(elements, pt.Folders[n-1].Name) + elements = append(elements, folderIcon) } - return pt.colorizePath(pt.root, elements) + return pt.colorizePath(root, elements) } func (pt *Path) getAgnosterLeftPath() string { + root := pt.root + folders := pt.folders folderIcon := pt.props.GetString(FolderIcon, "..") - if pt.root == pt.pathSeparator { - pt.root = pt.Folders[0].Name - pt.Folders = pt.Folders[1:] + if pt.isRootFS(root) { + root = folders[0].Name + folders = folders[1:] } var elements []string - n := len(pt.Folders) - elements = append(elements, pt.Folders[0].Name) - for i := 1; i < n; i++ { - if pt.Folders[i].Display { - elements = append(elements, pt.Folders[i].Name) + elements = append(elements, folders[0].Name) + for i, n := 1, len(folders); i < n; i++ { + if folders[i].Display { + elements = append(elements, folders[i].Name) continue } elements = append(elements, folderIcon) } - return pt.colorizePath(pt.root, elements) + return pt.colorizePath(root, elements) } func (pt *Path) getRelevantLetter(folder *Folder) string { @@ -365,62 +390,78 @@ func (pt *Path) getRelevantLetter(folder *Folder) string { } func (pt *Path) getLetterPath() string { - if pt.root == pt.pathSeparator { - pt.root = pt.Folders[0].Name - pt.Folders = pt.Folders[1:] + root := pt.root + folders := pt.folders + + if pt.isRootFS(root) { + root = folders[0].Name + folders = folders[1:] } - pt.root = pt.getRelevantLetter(&Folder{Name: pt.root}) + root = pt.getRelevantLetter(&Folder{Name: root}) var elements []string - n := len(pt.Folders) - for i := 0; i < n-1; i++ { - if pt.Folders[i].Display { - elements = append(elements, pt.Folders[i].Name) + for i, n := 0, len(folders); i < n; i++ { + if folders[i].Display || i == n-1 { + elements = append(elements, folders[i].Name) continue } - letter := pt.getRelevantLetter(pt.Folders[i]) + letter := pt.getRelevantLetter(folders[i]) elements = append(elements, letter) } - if len(pt.Folders) > 0 { - elements = append(elements, pt.Folders[n-1].Name) - } - - return pt.colorizePath(pt.root, elements) + return pt.colorizePath(root, elements) } func (pt *Path) getUniqueLettersPath(maxWidth int) string { + root := pt.root + folders := pt.folders separator := pt.getFolderSeparator() - if pt.root == pt.pathSeparator { - pt.root = pt.Folders[0].Name - pt.Folders = pt.Folders[1:] + if pt.isRootFS(root) { + root = folders[0].Name + folders = folders[1:] + } + + folderNames := folders.List() + + usePowerlevelStyle := func(root, relative string) bool { + length := len(root) + len(relative) + if !pt.endWithSeparator(root) { + length += len(separator) + } + return length <= maxWidth } if maxWidth > 0 { - path := strings.Join(pt.Folders.List(), separator) - if len(path) <= maxWidth { - return pt.colorizePath(pt.root, pt.Folders.List()) + relative := strings.Join(folderNames, separator) + if usePowerlevelStyle(root, relative) { + return pt.colorizePath(root, folderNames) } } - pt.root = pt.getRelevantLetter(&Folder{Name: pt.root}) + root = pt.getRelevantLetter(&Folder{Name: root}) var elements []string - n := len(pt.Folders) letters := make(map[string]bool) - letters[pt.root] = true - for i := 0; i < n-1; i++ { - folder := pt.Folders[i].Name - letter := pt.getRelevantLetter(pt.Folders[i]) + letters[root] = true + + for i, n := 0, len(folders); i < n; i++ { + folderName := folderNames[i] + + if i == n-1 { + elements = append(elements, folderName) + break + } + + letter := pt.getRelevantLetter(folders[i]) for letters[letter] { - if letter == folder { + if letter == folderName { break } - letter += folder[len(letter) : len(letter)+1] + letter += folderName[len(letter) : len(letter)+1] } letters[letter] = true @@ -429,87 +470,95 @@ func (pt *Path) getUniqueLettersPath(maxWidth int) string { // only return early on maxWidth > 0 // this enables the powerlevel10k behavior if maxWidth > 0 { - list := pt.Folders[i+1:].List() - list = append(list, elements...) - current := strings.Join(list, separator) - leftover := maxWidth - len(current) - len(pt.root) - len(separator) - if leftover >= 0 { - elements = append(elements, strings.Join(pt.Folders[i+1:].List(), separator)) - return pt.colorizePath(pt.root, elements) + list := elements + list = append(list, folderNames[i+1:]...) + relative := strings.Join(list, separator) + if usePowerlevelStyle(root, relative) { + return pt.colorizePath(root, list) } } } - if len(pt.Folders) > 0 { - elements = append(elements, pt.Folders[n-1].Name) - } - - return pt.colorizePath(pt.root, elements) + return pt.colorizePath(root, elements) } func (pt *Path) getAgnosterFullPath() string { - if pt.root == pt.pathSeparator { - pt.root = pt.Folders[0].Name - pt.Folders = pt.Folders[1:] + root := pt.root + folders := pt.folders + + if pt.isRootFS(root) { + root = folders[0].Name + folders = folders[1:] } - return pt.colorizePath(pt.root, pt.Folders.List()) + return pt.colorizePath(root, folders.List()) } func (pt *Path) getAgnosterShortPath() string { - pathDepth := len(pt.Folders) + root := pt.root + folders := pt.folders + + if pt.isRootFS(root) { + root = folders[0].Name + folders = folders[1:] + } maxDepth := pt.props.GetInt(MaxDepth, 1) if maxDepth < 1 { maxDepth = 1 } - folderIcon := pt.props.GetString(FolderIcon, "..") + pathDepth := len(folders) hideRootLocation := pt.props.GetBool(HideRootLocation, false) + folderIcon := pt.props.GetString(FolderIcon, "..") - if pathDepth <= maxDepth { - if hideRootLocation { - pt.root = folderIcon - } + // No need to shorten. + if pathDepth < maxDepth || (pathDepth == maxDepth && !hideRootLocation) { return pt.getAgnosterFullPath() } - splitPos := pathDepth - maxDepth - - var folders []string - // unix root, needs to be replaced with the folder we're in at root level - root := pt.root - room := pathDepth - maxDepth - if root == pt.pathSeparator { - root = pt.Folders[0].Name - room-- - } + elements := []string{folderIcon} - if hideRootLocation || room > 0 { - folders = append(folders, folderIcon) + for i := pathDepth - maxDepth; i < pathDepth; i++ { + elements = append(elements, folders[i].Name) } if hideRootLocation { - root = "" + return pt.colorizePath(elements[0], elements[1:]) } - for i := splitPos; i < pathDepth; i++ { - folders = append(folders, pt.Folders[i].Name) - } - - return pt.colorizePath(root, folders) + return pt.colorizePath(root, elements) } func (pt *Path) getFullPath() string { - return pt.colorizePath(pt.root, pt.Folders.List()) + return pt.colorizePath(pt.root, pt.folders.List()) } func (pt *Path) getFolderPath() string { - return pt.colorizePath(runtime.Base(pt.env, pt.pwd), nil) + folderName := pt.folders[len(pt.folders)-1].Name + return pt.colorizePath(folderName, nil) +} + +func (pt *Path) join(root, relative string) string { + // this is a full replacement of the parent + if len(root) == 0 { + return relative + } + + if !pt.endWithSeparator(root) && len(relative) > 0 { + return root + pt.pathSeparator + relative + } + + return root + relative } -func (pt *Path) replaceMappedLocations() (string, string) { - mappedLocations := map[string]string{} +func (pt *Path) setMappedLocations() { + if pt.mappedLocations != nil { + return + } + + mappedLocations := make(map[string]string) + // predefined mapped locations, can be disabled if pt.props.GetBool(MappedLocationsEnabled, true) { mappedLocations["hkcu:"] = pt.props.GetString(WindowsRegistryIcon, "\uF013") @@ -520,7 +569,7 @@ func (pt *Path) replaceMappedLocations() (string, string) { // merge custom locations with mapped locations // mapped locations can override predefined locations keyValues := pt.props.GetKeyValueMap(MappedLocations, make(map[string]string)) - for key, val := range keyValues { + for key, value := range keyValues { if len(key) == 0 { continue } @@ -540,89 +589,86 @@ func (pt *Path) replaceMappedLocations() (string, string) { continue } - mappedLocations[pt.normalize(path)] = val + // When two templates resolve to the same key, the values are compared in ascending order and the latter is taken. + if v, exist := mappedLocations[pt.normalize(path)]; exist && value <= v { + continue + } + + mappedLocations[pt.normalize(path)] = value + } + + pt.mappedLocations = mappedLocations +} + +func (pt *Path) replaceMappedLocations(inputPath string) (string, string) { + root, relative := pt.parsePath(inputPath) + if len(relative) == 0 { + pt.RootDir = true + } + + pt.setMappedLocations() + if len(pt.mappedLocations) == 0 { + return root, relative } // sort map keys in reverse order // fixes case when a subfoder and its parent are mapped // ex /users/test and /users/test/dev - keys := make([]string, 0, len(mappedLocations)) - for k := range mappedLocations { + keys := make([]string, 0, len(pt.mappedLocations)) + for k := range pt.mappedLocations { keys = append(keys, k) } - sort.Sort(sort.Reverse(sort.StringSlice(keys))) - root, relative := pt.parsePath(pt.pwd) - if len(relative) == 0 { - pt.RootDir = true - } - rootN := pt.normalize(root) relativeN := pt.normalize(relative) + escape := func(path string) string { + // Escape chevron characters to avoid applying unexpected text styles. + return strings.NewReplacer("<", "<<>", ">", "<>>").Replace(path) + } + for _, key := range keys { keyRoot, keyRelative := pt.parsePath(key) - matchSubFolders := strings.HasSuffix(keyRelative, "*") + matchSubFolders := strings.HasSuffix(keyRelative, pt.pathSeparator+"*") - if matchSubFolders && len(keyRelative) > 1 { - keyRelative = keyRelative[0 : len(keyRelative)-1] // remove trailing /* or \* + if matchSubFolders { + // Remove the trailing wildcard (*). + keyRelative = keyRelative[:len(keyRelative)-1] } if keyRoot != rootN || !strings.HasPrefix(relativeN, keyRelative) { continue } - value := mappedLocations[key] + value := pt.mappedLocations[key] overflow := relative[len(keyRelative):] + // exactly match the full path if len(overflow) == 0 { - // exactly match the full path return value, "" } + // only match the root if len(keyRelative) == 0 { - // only match the root - return value, strings.Trim(relative, pt.pathSeparator) + return value, strings.Trim(escape(relative), pt.pathSeparator) } // match several prefix elements - if matchSubFolders || overflow[0:1] == pt.pathSeparator { - return value, strings.Trim(overflow, pt.pathSeparator) + if matchSubFolders || overflow[:1] == pt.pathSeparator { + return value, strings.Trim(escape(overflow), pt.pathSeparator) } } - return root, strings.Trim(relative, pt.pathSeparator) + return escape(root), strings.Trim(escape(relative), pt.pathSeparator) } -func (pt *Path) normalizePath(path string) string { - if pt.env.GOOS() != runtime.WINDOWS || pt.cygPath { - return path - } - - var clean []rune - for _, char := range path { - var lastChar rune - if len(clean) > 0 { - lastChar = clean[len(clean)-1:][0] - } - - if char == '/' && lastChar != 60 { // 60 == <, this is done to avoid replacing color codes - clean = append(clean, 92) // 92 == \ - continue - } - - clean = append(clean, char) - } - - return string(clean) -} - -// ParsePath parses an input path and returns a clean root and a clean path. +// parsePath parses a clean input path into a root and a relative. func (pt *Path) parsePath(inputPath string) (string, string) { - var root, path string + var root, relative string + if len(inputPath) == 0 { - return root, path + return root, relative } if pt.cygPath { @@ -638,76 +684,58 @@ func (pt *Path) parsePath(inputPath string) (string, string) { } } - clean := func(path string) string { - matches := regex.FindAllNamedRegexMatch(fmt.Sprintf(`(?P[^\%s]+)`, pt.pathSeparator), path) - n := len(matches) - 1 - s := new(strings.Builder) - for i, m := range matches { - s.WriteString(m["element"]) - if i != n { - s.WriteString(pt.pathSeparator) - } - } - return s.String() - } - - if pt.windowsPath { - inputPath = pt.normalizePath(inputPath) - // for a UNC path, extract \\hostname\sharename as the root - matches := regex.FindNamedRegexMatch(`^\\\\(?P[^\\]+)\\+(?P[^\\]+)\\*(?P[\s\S]*)$`, inputPath) + if pt.env.GOOS() == runtime.WINDOWS { + // Handle a UNC path, if any. + pattern := fmt.Sprintf(`^\%[1]s{2}(?P[^\%[1]s]+)\%[1]s(?P[^\%[1]s]+)(\%[1]s(?P[\s\S]*))?$`, pt.pathSeparator) + matches := regex.FindNamedRegexMatch(pattern, inputPath) if len(matches) > 0 { - root = `\\` + matches["hostname"] + `\` + matches["sharename"] - path = clean(matches["path"]) - return root, path + root = fmt.Sprintf(`%[1]s%[1]s%[2]s%[1]s%[3]s`, pt.pathSeparator, matches["hostname"], matches["sharename"]) + relative = matches["path"] + return root, relative } } s := strings.SplitAfterN(inputPath, pt.pathSeparator, 2) root = s[0] - if pt.windowsPath { - root = strings.TrimSuffix(root, pt.pathSeparator) - } - if len(s) == 2 { - path = clean(s[1]) + if len(root) > 1 { + root = root[:len(root)-1] + } + + relative = s[1] } - return root, path + return root, relative +} + +func (pt *Path) isRootFS(path string) bool { + return len(path) == 1 && runtime.IsPathSeparator(pt.env, path[0]) +} + +func (pt *Path) endWithSeparator(path string) bool { + if len(path) == 0 { + return false + } + return runtime.IsPathSeparator(pt.env, path[len(path)-1]) } func (pt *Path) normalize(inputPath string) string { normalized := inputPath + if strings.HasPrefix(normalized, "~") && (len(normalized) == 1 || runtime.IsPathSeparator(pt.env, normalized[1])) { normalized = pt.env.Home() + normalized[1:] } - if pt.cygPath { - return normalized - } + normalized = runtime.CleanPath(pt.env, normalized) - switch pt.env.GOOS() { - case runtime.WINDOWS: - normalized = pt.normalizePath(normalized) - fallthrough - case runtime.DARWIN: + if pt.env.GOOS() == runtime.WINDOWS || pt.env.GOOS() == runtime.DARWIN { normalized = strings.ToLower(normalized) } return normalized } -func (pt *Path) replaceFolderSeparators(pwd string) string { - defaultSeparator := pt.pathSeparator - folderSeparator := pt.getFolderSeparator() - if folderSeparator == defaultSeparator { - return pwd - } - - pwd = strings.ReplaceAll(pwd, defaultSeparator, folderSeparator) - return pwd -} - func (pt *Path) colorizePath(root string, elements []string) string { cycle := pt.props.GetStringArray(Cycle, []string{}) skipColorize := len(cycle) == 0 @@ -730,8 +758,8 @@ func (pt *Path) colorizePath(root string, elements []string) string { } if len(elements) == 0 { - root = fmt.Sprintf(leftFormat, root) - return colorizeElement(root) + formattedRoot := fmt.Sprintf(leftFormat, root) + return colorizeElement(formattedRoot) } colorizeSeparator := func() string { @@ -741,13 +769,13 @@ func (pt *Path) colorizePath(root string, elements []string) string { return fmt.Sprintf("<%s>%s", cycle[0], folderSeparator) } - var builder strings.Builder + sb := new(strings.Builder) formattedRoot := fmt.Sprintf(leftFormat, root) - builder.WriteString(colorizeElement(formattedRoot)) + sb.WriteString(colorizeElement(formattedRoot)) - if root != pt.pathSeparator && len(root) != 0 { - builder.WriteString(colorizeSeparator()) + if !pt.endWithSeparator(root) { + sb.WriteString(colorizeSeparator()) } for i, element := range elements { @@ -760,76 +788,49 @@ func (pt *Path) colorizePath(root string, elements []string) string { format = rightFormat } - element = fmt.Sprintf(format, element) - builder.WriteString(colorizeElement(element)) + formattedElement := fmt.Sprintf(format, element) + sb.WriteString(colorizeElement(formattedElement)) if i != len(elements)-1 { - builder.WriteString(colorizeSeparator()) + sb.WriteString(colorizeSeparator()) } } - return builder.String() -} - -type Folder struct { - Name string - Display bool - Path string -} - -type Folders []*Folder - -func (f Folders) List() []string { - var list []string - - for _, folder := range f { - list = append(list, folder.Name) - } - - return list + return sb.String() } func (pt *Path) splitPath() Folders { - result := Folders{} - folders := []string{} + folders := Folders{} - if len(pt.relative) != 0 { - folders = strings.Split(pt.relative, pt.pathSeparator) + if len(pt.relative) == 0 { + return folders } + elements := strings.Split(pt.relative, pt.pathSeparator) folderFormatMap := pt.makeFolderFormatMap() + currentPath := pt.root - getCurrentPath := func() string { - if pt.root == "~" { - return pt.env.Home() + pt.pathSeparator - } - - if pt.windowsPath { - return pt.root + pt.pathSeparator - } - - return pt.root + if !pt.endWithSeparator(pt.root) { + currentPath += pt.pathSeparator } - currentPath := getCurrentPath() - var display bool - for _, folder := range folders { - currentPath += folder + for _, element := range elements { + currentPath += element if format := folderFormatMap[currentPath]; len(format) != 0 { - folder = fmt.Sprintf(format, folder) + element = fmt.Sprintf(format, element) display = true } - result = append(result, &Folder{Name: folder, Path: currentPath, Display: display}) + folders = append(folders, &Folder{Name: element, Path: currentPath, Display: display}) currentPath += pt.pathSeparator display = false } - return result + return folders } func (pt *Path) makeFolderFormatMap() map[string]string { @@ -838,7 +839,9 @@ func (pt *Path) makeFolderFormatMap() map[string]string { if gitDirFormat := pt.props.GetString(GitDirFormat, ""); len(gitDirFormat) != 0 { dir, err := pt.env.HasParentFilePath(".git", false) if err == nil && dir.IsDir { - folderFormatMap[dir.ParentFolder] = gitDirFormat + // Make it consistent with the modified path. + path := pt.join(pt.replaceMappedLocations(dir.ParentFolder)) + folderFormatMap[path] = gitDirFormat } } diff --git a/src/segments/path_test.go b/src/segments/path_test.go index 51adda43e707..2f84839e798c 100644 --- a/src/segments/path_test.go +++ b/src/segments/path_test.go @@ -459,7 +459,7 @@ func TestAgnosterPathStyles(t *testing.T) { }, { Style: AgnosterFull, - Expected: "PSDRIVE:/ | src", + Expected: "PSDRIVE: | src", HomePath: homeDir, Pwd: "/foo", Pswd: "PSDRIVE:/src", @@ -489,7 +489,7 @@ func TestAgnosterPathStyles(t *testing.T) { }, { Style: AgnosterShort, - Expected: ".. | src", + Expected: "PSDRIVE: | src", HomePath: homeDir, Pwd: "/foo", Pswd: "PSDRIVE:/src", @@ -591,7 +591,7 @@ func TestAgnosterPathStyles(t *testing.T) { }, { Style: AgnosterShort, - Expected: "PSDRIVE:/ | .. | init", + Expected: "PSDRIVE: | .. | init", HomePath: homeDir, Pwd: "/foo", Pswd: "PSDRIVE:/src/init", @@ -630,7 +630,7 @@ func TestAgnosterPathStyles(t *testing.T) { }, { Style: AgnosterShort, - Expected: ".. > foo", + Expected: "~ > foo", HomePath: homeDir, Pwd: homeDir + "/foo", PathSeparator: "/", @@ -640,7 +640,7 @@ func TestAgnosterPathStyles(t *testing.T) { }, { Style: AgnosterShort, - Expected: ".. > foo > bar", + Expected: "~ > foo > bar", HomePath: homeDir, Pwd: homeDir + "/foo/bar", PathSeparator: "/", @@ -661,7 +661,7 @@ func TestAgnosterPathStyles(t *testing.T) { }, { Style: AgnosterShort, - Expected: ".. | space foo", + Expected: "~ | space foo", HomePath: homeDir, Pwd: homeDir + "/space foo", PathSeparator: "/", @@ -752,7 +752,7 @@ func TestAgnosterPathStyles(t *testing.T) { }, { Style: AgnosterShort, - Expected: ".. > foo", + Expected: "~ > foo", HomePath: homeDirWindows, Pwd: homeDirWindows + "\\foo", GOOS: runtime.WINDOWS, @@ -848,6 +848,7 @@ func TestFullAndFolderPath(t *testing.T) { // for Windows paths {Style: FolderType, FolderSeparatorIcon: "\\", Pwd: "C:\\", Expected: "C:\\", PathSeparator: "\\", GOOS: runtime.WINDOWS}, + {Style: FolderType, FolderSeparatorIcon: "\\", Pwd: "\\\\localhost\\d$", Expected: "\\\\localhost\\d$", PathSeparator: "\\", GOOS: runtime.WINDOWS}, {Style: FolderType, FolderSeparatorIcon: "\\", Pwd: homeDirWindows, Expected: "~", PathSeparator: "\\", GOOS: runtime.WINDOWS}, {Style: Full, FolderSeparatorIcon: "\\", Pwd: homeDirWindows, Expected: "~", PathSeparator: "\\", GOOS: runtime.WINDOWS}, {Style: Full, FolderSeparatorIcon: "\\", Pwd: homeDirWindows + "\\abc", Expected: "~\\abc", PathSeparator: "\\", GOOS: runtime.WINDOWS}, @@ -1001,41 +1002,6 @@ func TestFullPathCustomMappedLocations(t *testing.T) { } } -func TestPowerlevelMappedLocations(t *testing.T) { - cases := []struct { - Pwd string - MappedLocations map[string]string - Expected string - }{ - {Pwd: "/Users/michal/Applications", MappedLocations: map[string]string{"~": "#"}, Expected: "#/Applications"}, - } - - for _, tc := range cases { - env := new(mock.Environment) - env.On("Home").Return("/Users/michal") - env.On("Pwd").Return(tc.Pwd) - env.On("GOOS").Return(runtime.DARWIN) - env.On("PathSeparator").Return("/") - env.On("Shell").Return(shell.GENERIC) - env.On("DebugF", testify_.Anything, testify_.Anything).Return(nil) - env.On("Flags").Return(&runtime.Flags{}) - - path := &Path{ - env: env, - props: properties.Map{ - properties.Style: Powerlevel, - MappedLocations: tc.MappedLocations, - }, - } - - path.setPaths() - path.setStyle() - - got := renderTemplateNoTrimSpace(env, "{{ .Path }}", path) - assert.Equal(t, tc.Expected, got) - } -} - func TestFolderPathCustomMappedLocations(t *testing.T) { pwd := abcd env := new(mock.Environment) @@ -1501,46 +1467,155 @@ func TestGetFolderSeparator(t *testing.T) { func TestNormalizePath(t *testing.T) { cases := []struct { - Input string - HomeDir string - GOOS string - Expected string + Case string + Input string + HomeDir string + GOOS string + PathSeparator string + Expected string }{ - {Input: "/foo/~/bar", HomeDir: homeDirWindows, GOOS: runtime.WINDOWS, Expected: "\\foo\\~\\bar"}, - {Input: homeDirWindows + "\\Foo", HomeDir: homeDirWindows, GOOS: runtime.WINDOWS, Expected: "c:\\users\\someone\\foo"}, - {Input: "~/Bob\\Foo", HomeDir: homeDir, GOOS: runtime.LINUX, Expected: homeDir + "/Bob\\Foo"}, - {Input: "~/Bob\\Foo", HomeDir: homeDir, GOOS: runtime.DARWIN, Expected: homeDir + "/bob\\foo"}, - {Input: "~\\Bob\\Foo", HomeDir: homeDirWindows, GOOS: runtime.WINDOWS, Expected: "c:\\users\\someone\\bob\\foo"}, - {Input: "/foo/~/bar", HomeDir: homeDir, GOOS: runtime.LINUX, Expected: "/foo/~/bar"}, - {Input: "~/baz", HomeDir: homeDir, GOOS: runtime.LINUX, Expected: homeDir + "/baz"}, - {Input: "~/baz", HomeDir: homeDirWindows, GOOS: runtime.WINDOWS, Expected: "c:\\users\\someone\\baz"}, + { + Case: "Windows: absolute w/o drive letter, forward slash included", + Input: "/foo/~/bar", + HomeDir: homeDirWindows, + GOOS: runtime.WINDOWS, + PathSeparator: `\`, + Expected: "\\foo\\~\\bar", + }, + { + Case: "Windows: absolute", + Input: homeDirWindows + "\\Foo", + HomeDir: homeDirWindows, + GOOS: runtime.WINDOWS, + PathSeparator: `\`, + Expected: "c:\\users\\someone\\foo", + }, + { + Case: "Linux: home prefix, backslash included", + Input: "~/Bob\\Foo", + HomeDir: homeDir, + GOOS: runtime.LINUX, + Expected: homeDir + "/Bob\\Foo", + }, + { + Case: "macOS: home prefix, backslash included", + Input: "~/Bob\\Foo", + HomeDir: homeDir, + GOOS: runtime.DARWIN, + Expected: homeDir + "/bob\\foo", + }, + { + Case: "Windows: home prefix", + Input: "~\\Bob\\Foo", + HomeDir: homeDirWindows, + GOOS: runtime.WINDOWS, + PathSeparator: `\`, + Expected: "c:\\users\\someone\\bob\\foo", + }, + { + Case: "Linux: absolute", + Input: "/foo/~/bar", + HomeDir: homeDir, + GOOS: runtime.LINUX, + Expected: "/foo/~/bar", + }, + { + Case: "Linux: home prefix", + Input: "~/baz", + HomeDir: homeDir, + GOOS: runtime.LINUX, + Expected: homeDir + "/baz", + }, + { + Case: "Windows: home prefix", + Input: "~/baz", + HomeDir: homeDirWindows, + GOOS: runtime.WINDOWS, + PathSeparator: `\`, + Expected: "c:\\users\\someone\\baz", + }, + { + Case: "Windows: UNC root w/ prefix", + Input: `\\.\UNC\localhost\c$`, + HomeDir: homeDirWindows, + GOOS: runtime.WINDOWS, + PathSeparator: `\`, + Expected: "\\\\localhost\\c$", + }, + { + Case: "Windows: UNC root w/ prefix, forward slash included", + Input: "//./UNC/localhost/c$", + HomeDir: homeDirWindows, + GOOS: runtime.WINDOWS, + PathSeparator: `\`, + Expected: "\\\\localhost\\c$", + }, + { + Case: "Windows: UNC root", + Input: `\\localhost\c$\`, + HomeDir: homeDirWindows, + GOOS: runtime.WINDOWS, + PathSeparator: `\`, + Expected: "\\\\localhost\\c$", + }, + { + Case: "Windows: UNC root, forward slash included", + Input: "//localhost/c$", + HomeDir: homeDirWindows, + GOOS: runtime.WINDOWS, + PathSeparator: `\`, + Expected: "\\\\localhost\\c$", + }, + { + Case: "Windows: UNC", + Input: `\\localhost\c$\some`, + HomeDir: homeDirWindows, + GOOS: runtime.WINDOWS, + PathSeparator: `\`, + Expected: "\\\\localhost\\c$\\some", + }, + { + Case: "Windows: UNC, forward slash included", + Input: "//localhost/c$/some", + HomeDir: homeDirWindows, + GOOS: runtime.WINDOWS, + PathSeparator: `\`, + Expected: "\\\\localhost\\c$\\some", + }, } for _, tc := range cases { env := new(mock.Environment) env.On("Home").Return(tc.HomeDir) env.On("GOOS").Return(tc.GOOS) - pt := &Path{ - env: env, + + if len(tc.PathSeparator) == 0 { + tc.PathSeparator = "/" } + + env.On("PathSeparator").Return(tc.PathSeparator) + pt := &Path{env: env} got := pt.normalize(tc.Input) - assert.Equal(t, tc.Expected, got) + assert.Equal(t, tc.Expected, got, tc.Case) } } func TestReplaceMappedLocations(t *testing.T) { cases := []struct { - Case string - Pwd string - Expected string + Case string + Pwd string + MappedLocationsEnabled bool + Expected string }{ {Pwd: "/c/l/k/f", Expected: "f"}, {Pwd: "/f/g/h", Expected: "/f/g/h"}, {Pwd: "/f/g/h/e", Expected: "^/e"}, {Pwd: abcd, Expected: "#"}, {Pwd: "/a/b/c/d/e", Expected: "#/e"}, - {Pwd: "/a/b/c/d/e", Expected: "#/e"}, + {Pwd: "/a/b/c/D/e", Expected: "#/e"}, {Pwd: "/a/b/k/j/e", Expected: "e"}, + {Pwd: "/a/b/k/l", Expected: "@/l"}, + {Pwd: "/a/b/k/l", MappedLocationsEnabled: true, Expected: "~/l"}, } for _, tc := range cases { @@ -1556,11 +1631,12 @@ func TestReplaceMappedLocations(t *testing.T) { path := &Path{ env: env, props: properties.Map{ - MappedLocationsEnabled: false, + MappedLocationsEnabled: tc.MappedLocationsEnabled, MappedLocations: map[string]string{ abcd: "#", "/f/g/h/*": "^", "/c/l/k/*": "", + "~": "@", "~/j/*": "", }, }, @@ -1600,8 +1676,8 @@ func TestSplitPath(t *testing.T) { GitDir: &runtime.FileInfo{IsDir: true, ParentFolder: "/a/b/c"}, GitDirFormat: "%s", Expected: Folders{ - {Name: "c", Path: "/a/b/c", Display: true}, - {Name: "d", Path: "/a/b/c/d"}, + {Name: "c", Path: "~/c", Display: true}, + {Name: "d", Path: "~/c/d"}, }, }, { @@ -1622,6 +1698,7 @@ func TestSplitPath(t *testing.T) { for _, tc := range cases { env := new(mock.Environment) + env.On("PathSeparator").Return("/") env.On("Home").Return("/a/b") env.On("HasParentFilePath", ".git", false).Return(tc.GitDir, nil) env.On("GOOS").Return(tc.GOOS) diff --git a/src/terminal/writer.go b/src/terminal/writer.go index 80f3f4bd3eb3..baee0ffec5c4 100644 --- a/src/terminal/writer.go +++ b/src/terminal/writer.go @@ -86,6 +86,8 @@ const ( hyperLinkText = "" hyperLinkTextEnd = "" + empty = "<>" + startProgress = "\x1b]9;4;3;0\x07" endProgress = "\x1b]9;4;0;0\x07" @@ -160,10 +162,6 @@ func Pwd(pwdType, userName, hostName, pwd string) string { return "" } - if strings.HasSuffix(pwd, ":") { - pwd += `/` - } - switch pwdType { case OSC7: return fmt.Sprintf(formats.Osc7, hostName, pwd) @@ -346,6 +344,9 @@ func Write(background, foreground color.Ansi, text string) { i += len([]rune(match[ANCHOR])) - 1 builder.WriteString(formats.HyperlinkEnd) continue + case empty: + i += len([]rune(match[ANCHOR])) - 1 + continue } i = writeArchorOverride(match, background, i) diff --git a/website/docs/segments/system/path.mdx b/website/docs/segments/system/path.mdx index c6f7f4becfd5..340ec2be5dff 100644 --- a/website/docs/segments/system/path.mdx +++ b/website/docs/segments/system/path.mdx @@ -77,7 +77,9 @@ For example, to swap out `C:\Users\Leet\GitHub` with a GitHub icon, you can do t - To make mapped locations work cross-platform, use `/` as the path separator, Oh My Posh will automatically match effective separators based on the running operating system. - If you want to match all child directories, you can use `*` as a wildcard, for example: - `"C:/Users/Bill/*": "$"` will turn `C:/Users/Bill/Downloads` into `$/Downloads`. + `"C:/Users/Bill/*": "$"` will turn `C:/Users/Bill/Downloads` into `$/Downloads` but leave `C:/Users/Bill` unchanged. +- To prevent mangling path elements, if you use any text style tags (e.g., `...`) in replacement values, + you should avoid using a chevron character (`<`/`>`) in the `folder_separator_icon` property, and vice versa. - The character `~` at the start of a mapped location will match the user's home directory. - The match is case-insensitive on Windows and macOS, but case-sensitive on other operating systems. This means that for user Bill, who has a user account `Bill` on Windows and `bill` on Linux, `~/Foo` might match