├── .config └── dotnet-tools.json ├── .github ├── dependabot.yml └── workflows │ └── build.yml ├── .gitignore ├── LICENSE ├── README.md ├── assets ├── logo-64x64.png ├── logo.png └── terminal.gif ├── extLauncher.Tests ├── AppTests.fs ├── ConsoleTests.fs ├── DomainTests.fs ├── Program.fs └── extLauncher.Tests.fsproj ├── extLauncher.sln └── extLauncher ├── App.fs ├── Console.fs ├── Domain.fs ├── Infra.fs ├── Program.fs └── extLauncher.fsproj /.config/dotnet-tools.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": 1, 3 | "isRoot": true, 4 | "tools": { 5 | "extLauncher": { 6 | "version": "1.0.0", 7 | "commands": [ 8 | "extLauncher" 9 | ] 10 | } 11 | } 12 | } -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | # Please see the documentation for all configuration options: 2 | # https://docs.github.com/github/administering-a-repository/configuration-options-for-dependency-updates 3 | 4 | version: 2 5 | updates: 6 | - package-ecosystem: "nuget" # See documentation for possible values 7 | directory: "/" # Location of package manifests 8 | schedule: 9 | interval: "daily" 10 | -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: .NET Core 2 | on: 3 | push: 4 | pull_request: 5 | release: 6 | types: 7 | - published 8 | env: 9 | # Stop wasting time caching packages 10 | DOTNET_SKIP_FIRST_TIME_EXPERIENCE: true 11 | # Disable sending usage data to Microsoft 12 | DOTNET_CLI_TELEMETRY_OPTOUT: true 13 | # Project name to pack and publish 14 | PROJECT_NAME: extLauncher 15 | # GitHub Packages Feed settings 16 | GITHUB_FEED: https://nuget.pkg.github.com/d-edge/ 17 | GITHUB_USER: akhansari 18 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 19 | # Official NuGet Feed settings 20 | NUGET_FEED: https://api.nuget.org/v3/index.json 21 | NUGET_KEY: ${{ secrets.NUGET_KEY }} 22 | jobs: 23 | build: 24 | runs-on: ${{ matrix.os }} 25 | strategy: 26 | matrix: 27 | os: [ ubuntu-latest, windows-latest, macos-latest ] 28 | steps: 29 | - name: Checkout 30 | uses: actions/checkout@v2 31 | - name: Setup .NET Core 32 | uses: actions/setup-dotnet@v1 33 | with: 34 | dotnet-version: 6.0.100 35 | - name: Restore 36 | run: dotnet restore 37 | - name: Build 38 | run: dotnet build -c Release --no-restore 39 | - name: Test 40 | run: dotnet test -c Release 41 | - name: Strip HTML from README 42 | uses: d-edge/strip-markdown-html@v0.2 43 | with: 44 | input-path: README.md 45 | - name: Pack 46 | if: matrix.os == 'ubuntu-latest' 47 | run: | 48 | if [ "$GITHUB_REF_TYPE" = "tag" ]; then 49 | arrTag=(${GITHUB_REF//\// }) 50 | version="${arrTag[2]}" 51 | echo Version: $version 52 | version="${version//v}" 53 | echo Clean Version: $version 54 | else 55 | git fetch --prune --unshallow --tags --quiet 56 | latestTag=$(git describe --tags --abbrev=0 2>/dev/null || echo 0.0.1) 57 | runId=$GITHUB_RUN_ID 58 | version="${latestTag//v}-build.${runId}" 59 | echo Non-release version: $version 60 | fi 61 | dotnet pack -v normal -c Release --no-restore --include-symbols --include-source -p:PackageVersion=$version $PROJECT_NAME/$PROJECT_NAME.*proj 62 | - name: Upload Artifact 63 | if: matrix.os == 'ubuntu-latest' 64 | uses: actions/upload-artifact@v2 65 | with: 66 | name: nupkg 67 | path: ./${{ env.PROJECT_NAME }}/nupkg/*.nupkg 68 | prerelease: 69 | needs: build 70 | if: github.ref == 'refs/heads/main' 71 | runs-on: ubuntu-latest 72 | steps: 73 | - name: Download Artifact 74 | uses: actions/download-artifact@v1 75 | with: 76 | name: nupkg 77 | - name: Push to GitHub Feed 78 | run: | 79 | for f in ./nupkg/*.nupkg 80 | do 81 | curl -vX PUT -u "$GITHUB_USER:$GITHUB_TOKEN" -F package=@$f $GITHUB_FEED 82 | done 83 | deploy: 84 | needs: build 85 | if: github.event_name == 'release' 86 | runs-on: ubuntu-latest 87 | steps: 88 | - uses: actions/checkout@v2 89 | - name: Setup .NET Core 90 | uses: actions/setup-dotnet@v1 91 | with: 92 | dotnet-version: 6.0.100 93 | - name: Download Artifact 94 | uses: actions/download-artifact@v1 95 | with: 96 | name: nupkg 97 | - name: Push to GitHub Feed 98 | run: | 99 | for f in ./nupkg/*.nupkg 100 | do 101 | curl -vX PUT -u "$GITHUB_USER:$GITHUB_TOKEN" -F package=@$f $GITHUB_FEED 102 | done 103 | - name: Push to NuGet Feed 104 | run: dotnet nuget push ./nupkg/*.nupkg --source $NUGET_FEED --skip-duplicate --api-key $NUGET_KEY 105 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.suo 2 | *.user 3 | .vs 4 | .idea 5 | .ionide 6 | *.ncrunchsolution* 7 | nCrunchTemp_* 8 | _NCrunch_* 9 | 10 | [Bb]in/ 11 | [Oo]bj/ 12 | 13 | paket.local 14 | paket-files 15 | 16 | # From https://gist.github.com/geoder101/aa2ab1ab417fbc52bb8b 17 | # NuGet Packages 18 | *.nupkg 19 | # The packages folder can be ignored because of Package Restore 20 | **/packages/* 21 | # except build/, which is used as an MSBuild target. 22 | !**/packages/build/ 23 | # Uncomment if necessary however generally it will be regenerated when needed 24 | #!**/packages/repositories.config 25 | # NuGet v3's project.json files produces more ignoreable files 26 | *.nuget.props 27 | *.nuget.targets 28 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | 2 | The MIT License (MIT) 3 | 4 | Copyright (c) 2022 Amin Khansari, D-EDGE 5 | 6 | Permission is hereby granted, free of charge, to any person obtaining a copy 7 | of this software and associated documentation files (the "Software"), to deal 8 | in the Software without restriction, including without limitation the rights 9 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 10 | copies of the Software, and to permit persons to whom the Software is 11 | furnished to do so, subject to the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be included in all 14 | copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 18 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 19 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 20 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 21 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 22 | SOFTWARE. 23 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |
2 | 3 |

4 | extLauncher logo 5 |

6 | 7 |

8 | actions build 9 | license 10 |

11 | 12 |
13 | 14 | extLauncher is a dotnet tool to search and launch quickly projects in the user's preferred application. extLauncher is maintained by folks at [D-EDGE](https://www.d-edge.com/). 15 | 16 |

17 | extLauncher terminal 18 |

19 | 20 | # Getting Started 21 | 22 | Install extLauncher as a global dotnet tool 23 | 24 | ``` bash 25 | dotnet tool install extLauncher -g 26 | ``` 27 | 28 | or as a dotnet local tool 29 | 30 | ``` bash 31 | dotnet new tool-manifest 32 | dotnet tool install extLauncher 33 | ```` 34 | 35 | # Usage 36 | 37 | ``` 38 | USAGE: 39 | extLauncher [OPTIONS] 40 | 41 | EXAMPLES: 42 | extLauncher index *.sln 43 | extLauncher index "(.*)[.](fs|cs)proj$" --regex 44 | extLauncher launcher mylauncher set execpath 45 | extLauncher launcher mylauncher remove 46 | extLauncher launcher vscode set /usr/bin/code --choose file --args="-r %s" 47 | extLauncher launcher vscode set "$env:LOCALAPPDATA\Programs\Microsoft VS Code\bin\code.cmd" --choose directory 48 | extLauncher launcher explorer set explorer.exe --choose directory 49 | 50 | OPTIONS: 51 | -h, --help Prints help information 52 | 53 | COMMANDS: 54 | prompt (default command) Type to search. Arrows Up/Down to navigate. Enter to launch. Escape to quit 55 | index Indexes all files recursively with a specific pattern which can be a wildcard (default) or a regular expression 56 | launcher Add, update or remove a launcher (optional) 57 | deindex Clears the current index 58 | info Prints the current pattern and all the indexed files 59 | refresh Updates the current index 60 | ``` 61 | 62 | # Build locally 63 | 64 | - Clone the repository 65 | - Open the repository 66 | - Invoke the tool by running the `dotnet tool run` command: `dotnet tool run extLauncher` (with your arguments) 67 | 68 | # Caches and data generated by extLauncher 69 | 70 | This tool maintains a database to improve its performance. You should be able to find it in the most obvious place for your operating system: 71 | 72 | - Windows: `%appdata%\Roaming\extLauncher\extLauncher.db` 73 | - Linux: `~/.config/extLauncher/extLauncher.db` 74 | 75 | # License 76 | 77 | [MIT](https://github.com/d-edge/extLauncher/blob/main/LICENSE) 78 | -------------------------------------------------------------------------------- /assets/logo-64x64.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/d-edge/extLauncher/6a397930ff35b12f3f49640a35857703e7e09ed5/assets/logo-64x64.png -------------------------------------------------------------------------------- /assets/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/d-edge/extLauncher/6a397930ff35b12f3f49640a35857703e7e09ed5/assets/logo.png -------------------------------------------------------------------------------- /assets/terminal.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/d-edge/extLauncher/6a397930ff35b12f3f49640a35857703e7e09ed5/assets/terminal.gif -------------------------------------------------------------------------------- /extLauncher.Tests/AppTests.fs: -------------------------------------------------------------------------------- 1 | module extLauncher.AppTests 2 | 3 | open Swensen.Unquote 4 | open Xunit 5 | 6 | [] 7 | let ``should load a folder`` () = 8 | let folderPath = "/test" 9 | let pattern = "*.ext" 10 | let folder = 11 | let loadFiles _ _ = 12 | [| "/test/file2.ext", "file2" 13 | "/test/file1.ext", "file1" |] 14 | App.loadFolder loadFiles 15 | { Path = folderPath 16 | Pattern = Pattern.from pattern false 17 | Launchers = Array.empty } 18 | folder =! Some 19 | { Id = folderPath 20 | Pattern = pattern 21 | IsRegex = false 22 | Files = 23 | [| File.create "/test/file1.ext" "file1" 24 | File.create "/test/file2.ext" "file2" |] 25 | Launchers = Array.empty } 26 | 27 | [] 28 | let ``should not load a folder if no result`` () = 29 | let folder = 30 | let loadFiles _ _ = Array.empty 31 | App.loadFolder loadFiles 32 | { Path = "" 33 | Pattern = Pattern.from "" false 34 | Launchers = Array.empty } 35 | folder =! None 36 | 37 | [] 38 | let ``refresh should synchronize files`` () = 39 | let newFolder = 40 | let loadFiles _ _ = 41 | [| "file1", "" 42 | "file3", "" |] 43 | let save = id 44 | { Id = "" 45 | Pattern = "" 46 | IsRegex = false 47 | Files = 48 | [| File.create "file1" "" 49 | File.create "file2" "" |] 50 | Launchers = Array.empty } 51 | |> App.refresh loadFiles save 52 | |> Option.get 53 | 54 | newFolder.Files.[0].Id =! "file1" 55 | newFolder.Files.[1].Id =! "file3" 56 | 57 | [] 58 | let ``refresh should keep triggers`` () = 59 | let newFolder = 60 | let loadFiles _ _ = 61 | [| "file1", "" 62 | "file2", "" |] 63 | let save = id 64 | { Id = "" 65 | Pattern = "" 66 | IsRegex = false 67 | Files = 68 | [| File.create "file1" "" |> File.triggered 69 | File.create "file2" "" |] 70 | Launchers = Array.empty } 71 | |> App.refresh loadFiles save 72 | |> Option.get 73 | 74 | newFolder.Files.[0].Triggered =! 1 75 | newFolder.Files.[1].Triggered =! 0 76 | -------------------------------------------------------------------------------- /extLauncher.Tests/ConsoleTests.fs: -------------------------------------------------------------------------------- 1 | module extLauncher.ConsoleTests 2 | 3 | open System 4 | open System.Collections.Generic 5 | open Swensen.Unquote 6 | open Xunit 7 | 8 | let Terminal 9 | (strReader: Queue) 10 | (keyReader: Queue) 11 | (buffer: ResizeArray) 12 | = 13 | let mutable left = 0 14 | let mutable top = 0 15 | { new Console.ITerminal with 16 | member _.ShowCursor() = () 17 | member _.HideCursor() = () 18 | member _.ToggleCursorVisibility() = () 19 | member _.GetCursorPosition() = 20 | left, top 21 | member _.SetCursorPosition(l, t) = 22 | left <- l 23 | top <- t 24 | member _.ReadKey () = 25 | keyReader.Dequeue() 26 | member _.ReadLine() = 27 | strReader.Dequeue() 28 | member _.Write str = 29 | if top < buffer.Count then 30 | buffer[top] <- buffer[top].Insert(left, str) 31 | else 32 | buffer.Add str 33 | left <- str.Length 34 | member _.WriteLine str = 35 | if top < buffer.Count then 36 | buffer[top] <- str 37 | else 38 | buffer.Add str 39 | top <- top + 1 40 | left <- 0 41 | member this.Markup str = this.Write str 42 | member this.MarkupLine str = this.WriteLine str 43 | member _.ClearLine() = 44 | buffer[top] <- String.Empty 45 | left <- 0 46 | } 47 | 48 | let newTerminal = Terminal (Queue()) 49 | let noConsoleModifier = enum 0 50 | let enterKey = ConsoleKey.Enter, char ConsoleKey.Enter, noConsoleModifier 51 | let downKey = ConsoleKey.DownArrow, char ConsoleKey.DownArrow, noConsoleModifier 52 | let upKey = ConsoleKey.UpArrow, char ConsoleKey.UpArrow, noConsoleModifier 53 | let backspaceKey = ConsoleKey.Backspace, char ConsoleKey.Backspace, noConsoleModifier 54 | let escapeKey = ConsoleKey.Escape, char ConsoleKey.Escape, ConsoleModifiers.Alt 55 | let aKey k = enum(Char.ToUpper k |> int), k, noConsoleModifier 56 | let [] PromptTitle = "Search and choose" 57 | let [] PrintedTitle = "[teal]" + PromptTitle + "[/] " 58 | 59 | let printedLines maxChoices itemsCount chosenNum = [ 60 | PrintedTitle 61 | for n in 1..itemsCount do 62 | $"""[yellow]{if n = chosenNum then ">" else " "} [/]{n}""" 63 | for _ in itemsCount+1..maxChoices do 64 | "" 65 | ] 66 | 67 | [] 68 | let ``prompt should print choices`` () = 69 | let lines = ResizeArray() 70 | let keyReader = Queue [ escapeKey ] 71 | let term = newTerminal keyReader lines 72 | 73 | let _ = 74 | fun _ -> [| 1; 2; 3 |] 75 | |> Console.prompt term PromptTitle 5 76 | 77 | List.ofSeq lines =! printedLines 5 3 1 78 | 79 | [] 80 | let ``prompt should go down`` () = 81 | let lines = ResizeArray() 82 | let keyReader = Queue [ downKey; escapeKey ] 83 | let term = newTerminal keyReader lines 84 | 85 | let _ = 86 | fun _ -> [| 1; 2; 3 |] 87 | |> Console.prompt term PromptTitle 5 88 | 89 | List.ofSeq lines =! printedLines 5 3 2 90 | 91 | [] 92 | let ``prompt should go up`` () = 93 | let lines = ResizeArray() 94 | let keyReader = Queue [ downKey; upKey; escapeKey ] 95 | let term = newTerminal keyReader lines 96 | 97 | let _ = 98 | fun _ -> [| 1; 2; 3 |] 99 | |> Console.prompt term PromptTitle 5 100 | 101 | List.ofSeq lines =! printedLines 5 3 1 102 | 103 | [] 104 | let ``prompt should stay up`` () = 105 | let lines = ResizeArray() 106 | let keyReader = Queue [ upKey; upKey; escapeKey ] 107 | let term = newTerminal keyReader lines 108 | 109 | let _ = 110 | fun _ -> [| 1; 2; 3 |] 111 | |> Console.prompt term PromptTitle 5 112 | 113 | List.ofSeq lines =! printedLines 5 3 1 114 | 115 | [] 116 | let ``prompt should stay down`` () = 117 | let lines = ResizeArray() 118 | let keyReader = Queue [ downKey; downKey; downKey; downKey; downKey; escapeKey ] 119 | let term = newTerminal keyReader lines 120 | 121 | let _ = 122 | fun _ -> [| 1; 2; 3 |] 123 | |> Console.prompt term PromptTitle 5 124 | 125 | List.ofSeq lines =! printedLines 5 3 3 126 | 127 | [] 128 | let ``prompt should choose the second choice and clear`` () = 129 | let lines = ResizeArray() 130 | let keyReader = Queue [ downKey; enterKey ] 131 | let term = newTerminal keyReader lines 132 | 133 | let chosen = 134 | fun _ -> [| 1; 2; 3 |] 135 | |> Console.prompt term PromptTitle 5 136 | 137 | chosen =! Some 2 138 | Seq.forall ((=) "") lines =! true 139 | 140 | [] 141 | let ``prompt should print error if no match`` () = 142 | let lines = ResizeArray() 143 | let keyReader = Queue [ escapeKey ] 144 | let term = newTerminal keyReader lines 145 | 146 | let _ = 147 | fun _ -> Array.empty 148 | |> Console.prompt term PromptTitle 1 149 | 150 | List.ofSeq lines =! [ 151 | PrintedTitle 152 | "No items match your search." 153 | ] 154 | 155 | [] 156 | let ``prompt should print the search title`` () = 157 | let lines = ResizeArray() 158 | let keyReader = Queue [ aKey 't'; aKey 'e'; aKey 's'; aKey 't'; escapeKey ] 159 | let term = newTerminal keyReader lines 160 | 161 | let _ = 162 | fun _ -> [| 1; 2; 3 |] 163 | |> Console.prompt term PromptTitle 1 164 | 165 | Seq.head lines =! $"{PrintedTitle}test" 166 | 167 | [] 168 | let ``prompt should print the search chars supporting backspace`` () = 169 | let lines = ResizeArray() 170 | let keyReader = Queue [ aKey 't'; aKey 'e'; backspaceKey; aKey 's'; aKey 't'; escapeKey ] 171 | let term = newTerminal keyReader lines 172 | 173 | let _ = 174 | fun _ -> [| 1; 2; 3 |] 175 | |> Console.prompt term PromptTitle 1 176 | 177 | Seq.head lines =! $"{PrintedTitle}tst" 178 | 179 | [] 180 | let ``prompt should clear when exit`` () = 181 | let lines = ResizeArray() 182 | let keyReader = Queue [ ConsoleKey.Escape, char ConsoleKey.Escape, noConsoleModifier ] 183 | let term = newTerminal keyReader lines 184 | 185 | let _ = 186 | fun _ -> [| 1; 2; 3 |] 187 | |> Console.prompt term PromptTitle 5 188 | 189 | Seq.forall ((=) "") lines =! true 190 | -------------------------------------------------------------------------------- /extLauncher.Tests/DomainTests.fs: -------------------------------------------------------------------------------- 1 | module extLauncher.DomainTests 2 | 3 | open System 4 | open FsCheck.Xunit 5 | open Swensen.Unquote 6 | 7 | [] 8 | let ``File with the same Id should be equal`` (file1: File) (file2: File) = 9 | let file1 = { file1 with Id = file2.Id } 10 | file1 =! file2 11 | 12 | [] 13 | let ``File with different Id should not be equal`` (file1: File) (file2: File) = 14 | let file1 = { file1 with Id = Guid.NewGuid().ToString() } 15 | let file2 = { file2 with Id = Guid.NewGuid().ToString() } 16 | file1 <>! file2 17 | 18 | [] 19 | let ``File with a higher trigger should precede in the sort order`` (file1: File) (file2: File) = 20 | let file1 = { file1 with Triggered = file2.Triggered + 1 } 21 | compare file1 file2 =! -1 22 | 23 | [] 24 | let ``File with a lower trigger should follow in the sort order`` (file1: File) (file2: File) = 25 | let file1 = { file1 with Triggered = file2.Triggered - 1 } 26 | compare file1 file2 =! 1 27 | 28 | [] 29 | let ``File with the same trigger should be sorted alphabetically`` (file1: File) (file2: File) = 30 | let file1 = { file1 with Triggered = 0; Name = "a" } 31 | let file2 = { file2 with Triggered = 0; Name = "b" } 32 | compare file1 file2 =! -1 33 | 34 | [] 35 | let ``searchByName should search for the containing string ignoring case`` (file: File) (files: File array) = 36 | let file = { file with Name = "Hello World" } 37 | let files = Array.insertAt 0 file files 38 | Helpers.searchByName files "world" =! [| file |] 39 | -------------------------------------------------------------------------------- /extLauncher.Tests/Program.fs: -------------------------------------------------------------------------------- 1 | module Program = let [] main _ = 0 2 | -------------------------------------------------------------------------------- /extLauncher.Tests/extLauncher.Tests.fsproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | net6.0 5 | 6 | false 7 | false 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | runtime; build; native; contentfiles; analyzers; buildtransitive 24 | all 25 | 26 | 27 | runtime; build; native; contentfiles; analyzers; buildtransitive 28 | all 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | -------------------------------------------------------------------------------- /extLauncher.sln: -------------------------------------------------------------------------------- 1 |  2 | Microsoft Visual Studio Solution File, Format Version 12.00 3 | # Visual Studio Version 17 4 | VisualStudioVersion = 17.0.32112.339 5 | MinimumVisualStudioVersion = 10.0.40219.1 6 | Project("{6EC3EE1D-3C4E-46DD-8F32-0CC8E7565705}") = "extLauncher", "extLauncher\extLauncher.fsproj", "{075AB28F-511D-4C65-97B9-399F442EC8B8}" 7 | EndProject 8 | Project("{F2A71F9B-5D33-465A-A702-920D77279786}") = "extLauncher.Tests", "extLauncher.Tests\extLauncher.Tests.fsproj", "{687867BF-FB37-43BF-B0FF-148313A40D5B}" 9 | EndProject 10 | Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution Items", "{503F5C7D-95C0-4B13-8BC6-AAF31EA9FDE2}" 11 | ProjectSection(SolutionItems) = preProject 12 | README.md = README.md 13 | EndProjectSection 14 | EndProject 15 | Global 16 | GlobalSection(SolutionConfigurationPlatforms) = preSolution 17 | Debug|Any CPU = Debug|Any CPU 18 | Release|Any CPU = Release|Any CPU 19 | EndGlobalSection 20 | GlobalSection(ProjectConfigurationPlatforms) = postSolution 21 | {075AB28F-511D-4C65-97B9-399F442EC8B8}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 22 | {075AB28F-511D-4C65-97B9-399F442EC8B8}.Debug|Any CPU.Build.0 = Debug|Any CPU 23 | {075AB28F-511D-4C65-97B9-399F442EC8B8}.Release|Any CPU.ActiveCfg = Release|Any CPU 24 | {075AB28F-511D-4C65-97B9-399F442EC8B8}.Release|Any CPU.Build.0 = Release|Any CPU 25 | {687867BF-FB37-43BF-B0FF-148313A40D5B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 26 | {687867BF-FB37-43BF-B0FF-148313A40D5B}.Debug|Any CPU.Build.0 = Debug|Any CPU 27 | {687867BF-FB37-43BF-B0FF-148313A40D5B}.Release|Any CPU.ActiveCfg = Release|Any CPU 28 | {687867BF-FB37-43BF-B0FF-148313A40D5B}.Release|Any CPU.Build.0 = Release|Any CPU 29 | EndGlobalSection 30 | GlobalSection(SolutionProperties) = preSolution 31 | HideSolutionNode = FALSE 32 | EndGlobalSection 33 | GlobalSection(ExtensibilityGlobals) = postSolution 34 | SolutionGuid = {C8D85EC1-5DB0-4C30-BE67-D44D7E59789F} 35 | EndGlobalSection 36 | EndGlobal 37 | -------------------------------------------------------------------------------- /extLauncher/App.fs: -------------------------------------------------------------------------------- 1 | module extLauncher.App 2 | 3 | type FolderConf = 4 | { Path: string 5 | Pattern: Pattern 6 | Launchers: Launcher array } 7 | 8 | let loadFolder loadFiles conf : Folder option = 9 | loadFiles conf.Path conf.Pattern 10 | |> Array.map ((<||) File.create) 11 | |> Array.sort 12 | |> function 13 | | [||] -> None 14 | | files -> 15 | { Id = conf.Path 16 | Pattern = Pattern.value conf.Pattern 17 | IsRegex = Pattern.isRegex conf.Pattern 18 | Launchers = conf.Launchers 19 | Files = files } 20 | |> Some 21 | 22 | let index loadFiles save conf : Folder option = 23 | loadFolder loadFiles conf 24 | |> Option.map save 25 | 26 | let refresh loadFiles save (folder: Folder) : Folder option = 27 | 28 | let newFiles = 29 | Pattern.from folder.Pattern folder.IsRegex 30 | |> loadFiles folder.Id 31 | |> Array.map ((<||) File.create) 32 | 33 | let currentFiles = 34 | folder.Files 35 | |> Array.map (fun f -> f.Id, f) 36 | |> Map 37 | 38 | newFiles 39 | |> Array.map (fun newFile -> 40 | match currentFiles.TryFind newFile.Id with 41 | | Some current -> { newFile with Triggered = current.Triggered } 42 | | None -> newFile) 43 | |> fun files -> { folder with Files = files } 44 | |> save 45 | |> Some 46 | 47 | let makeSearcher folder str = 48 | Helpers.searchByName folder.Files str 49 | |> Array.sort 50 | -------------------------------------------------------------------------------- /extLauncher/Console.fs: -------------------------------------------------------------------------------- 1 | module extLauncher.Console 2 | 3 | open System 4 | open Spectre.Console 5 | 6 | type TerminalKey = ConsoleKey * char * ConsoleModifiers 7 | 8 | type ITerminal = 9 | abstract member ShowCursor: unit -> unit 10 | abstract member HideCursor: unit -> unit 11 | abstract member ToggleCursorVisibility: unit -> unit 12 | abstract member GetCursorPosition: unit -> int32 * int32 13 | abstract member SetCursorPosition: int32 * int32 -> unit 14 | abstract member ReadKey: unit -> TerminalKey 15 | abstract member ReadLine: unit -> string 16 | abstract member Write: string -> unit 17 | abstract member WriteLine: string -> unit 18 | abstract member Markup: string -> unit 19 | abstract member MarkupLine: string -> unit 20 | abstract member ClearLine: unit -> unit 21 | 22 | let Terminal = { new ITerminal with 23 | member _.ShowCursor () = 24 | Console.CursorVisible <- true 25 | member _.HideCursor () = 26 | Console.CursorVisible <- false 27 | member _.ToggleCursorVisibility () = 28 | Console.CursorVisible <- not Console.CursorVisible 29 | member _.GetCursorPosition () = 30 | Console.CursorLeft, Console.CursorTop 31 | member _.SetCursorPosition (left, top) = 32 | Console.SetCursorPosition( 33 | (if left < 0 then 0 else left), 34 | (if top < 0 then 0 else top)) 35 | member _.ReadKey () = 36 | let k = Console.ReadKey() 37 | k.Key, k.KeyChar, k.Modifiers 38 | member _.ReadLine () = 39 | Console.ReadLine() 40 | member _.Write str = AnsiConsole.Write str 41 | member _.WriteLine str = AnsiConsole.WriteLine str 42 | member _.Markup str = AnsiConsole.Markup str 43 | member _.MarkupLine str = AnsiConsole.MarkupLine str 44 | member this.ClearLine () = 45 | String(' ', Console.BufferWidth - 1) |> this.WriteLine 46 | } 47 | 48 | let [] NoMatch = "No items match your search." 49 | 50 | let clearUp (term: ITerminal) cursorTop count = 51 | for top = cursorTop to cursorTop + count do 52 | term.SetCursorPosition (0, top) 53 | term.ClearLine () 54 | term.SetCursorPosition (0, cursorTop) 55 | 56 | let checkNoMatch (term: ITerminal) (search: string -> 'T array) = 57 | if search String.Empty |> Array.isEmpty then 58 | term.MarkupLine NoMatch 59 | None 60 | else 61 | Some search 62 | 63 | let prompt<'T> (term: ITerminal) title maxChoices (search: string -> 'T array) = 64 | 65 | for _ in 0..maxChoices do term.WriteLine "" // allocate buffer area 66 | let cursorTop = 67 | let _, top = term.GetCursorPosition() 68 | top - maxChoices - 1 69 | 70 | let search str = 71 | let choices = search str |> Array.truncate maxChoices 72 | (choices, str, 0) 73 | 74 | let print (choices: 'T array, str, pos) = 75 | term.HideCursor () 76 | let pos = max 0 (min (Array.length choices - 1) pos) 77 | clearUp term cursorTop maxChoices 78 | term.WriteLine "" 79 | if Array.isEmpty choices then 80 | term.MarkupLine NoMatch 81 | else 82 | choices 83 | |> Array.iteri (fun i choice -> 84 | sprintf "[yellow]%s[/]%s" 85 | (if i = pos then "> " else " ") 86 | (string choice) 87 | |> term.MarkupLine) 88 | term.SetCursorPosition (0, cursorTop) 89 | term.Markup $"[teal]%s{title}[/] %s{str}" 90 | term.ShowCursor () 91 | (choices, str, pos) 92 | 93 | let rec read (choices: 'T array, str, pos) = 94 | match term.ReadKey () with 95 | | ConsoleKey.Escape, _, ConsoleModifiers.Alt -> 96 | None // no clear alternative 97 | | ConsoleKey.Escape, _, _ -> 98 | term.Write (string '\u200B') // hack to force the clear 99 | clearUp term cursorTop maxChoices 100 | None 101 | | ConsoleKey.Enter, _, _ -> 102 | if Array.isEmpty choices 103 | then read (choices, str, pos) 104 | else 105 | clearUp term cursorTop maxChoices 106 | Some choices[pos] 107 | | ConsoleKey.UpArrow, _, _ -> 108 | read ((choices, str, pos - 1) |> print) 109 | | ConsoleKey.DownArrow, _, _ -> 110 | read ((choices, str, pos + 1) |> print) 111 | | ConsoleKey.Backspace, _, _ -> 112 | if str.Length = 0 113 | then read (choices, str, pos) 114 | else read (search str[..^1] |> print) 115 | | _, key, _ -> 116 | read (search $"{str}{key}" |> print) 117 | 118 | search String.Empty |> print |> read 119 | -------------------------------------------------------------------------------- /extLauncher/Domain.fs: -------------------------------------------------------------------------------- 1 | namespace extLauncher 2 | 3 | open System 4 | 5 | [] 6 | type File = 7 | { Id: string 8 | Name: string 9 | Triggered: int32 } 10 | 11 | override this.ToString() = this.Name 12 | 13 | override this.GetHashCode() = this.Id.GetHashCode() 14 | 15 | override this.Equals other = 16 | match other with 17 | | :? File as other -> this.Id = other.Id 18 | | _ -> ArgumentException() |> raise 19 | 20 | interface IComparable with 21 | member this.CompareTo other = 22 | match other with 23 | | :? File as other -> 24 | if this.Triggered = other.Triggered then String.Compare(this.Name, other.Name, true) 25 | elif this.Triggered > other.Triggered then -1 26 | else 1 27 | | _ -> 28 | ArgumentException() |> raise 29 | 30 | module File = 31 | 32 | let create id name = 33 | { Id = id; Name = name; Triggered = 0 } 34 | 35 | let triggered file = 36 | { file with Triggered = file.Triggered + 1 } 37 | 38 | type Choose = 39 | | File = 0 40 | | Directory = 1 41 | 42 | type Launcher = 43 | { Name: string 44 | Path: string 45 | Arguments: string 46 | Choose: Choose } 47 | override this.ToString() = this.Name 48 | 49 | module Launcher = 50 | let buildArgs launcher tolaunch = 51 | if String.IsNullOrEmpty launcher.Arguments 52 | then tolaunch 53 | else launcher.Arguments.Replace("%s", $"\"{tolaunch}\"") 54 | 55 | // Should be serializable to BSON 56 | [] 57 | type Folder = 58 | { Id: string 59 | Pattern: string 60 | IsRegex: bool 61 | Launchers: Launcher array 62 | Files: File array } 63 | override this.ToString() = this.Id 64 | 65 | type Pattern = 66 | | WildcardPattern of string 67 | | RegexPattern of string 68 | 69 | module Pattern = 70 | let value = function WildcardPattern p | RegexPattern p -> p 71 | let isRegex = function WildcardPattern _ -> false | RegexPattern _ -> true 72 | let from value isRegex = if isRegex then RegexPattern value else WildcardPattern value 73 | 74 | module Helpers = 75 | 76 | let inline searchByName items str = 77 | if String.IsNullOrEmpty str then 78 | items 79 | else 80 | items 81 | |> Array.filter (fun item -> 82 | match (^T: (member Name: string) item) with 83 | | null -> false 84 | | name -> name.Contains(str, StringComparison.OrdinalIgnoreCase)) 85 | -------------------------------------------------------------------------------- /extLauncher/Infra.fs: -------------------------------------------------------------------------------- 1 | namespace extLauncher 2 | 3 | open System 4 | 5 | module IO = 6 | open System.IO 7 | open System.Text.RegularExpressions 8 | 9 | let [] AppName = "extLauncher" 10 | 11 | let userPath = 12 | let path = Path.Combine(Environment.GetFolderPath Environment.SpecialFolder.ApplicationData, AppName) 13 | Directory.CreateDirectory path |> ignore 14 | path 15 | 16 | let userPathCombine path = 17 | Path.Combine(userPath, path) 18 | 19 | let private enumerateFiles folderPath = function 20 | | WildcardPattern pattern -> 21 | Directory.EnumerateFiles(folderPath, pattern, SearchOption.AllDirectories) 22 | | RegexPattern pattern -> 23 | let regex = Regex pattern 24 | let opt = EnumerationOptions() 25 | opt.RecurseSubdirectories <- true 26 | Directory.EnumerateFiles(folderPath, "*", opt) 27 | |> Seq.filter (Path.GetFileName >> regex.IsMatch) 28 | 29 | let getFiles folderPath pattern = 30 | enumerateFiles folderPath pattern 31 | |> Seq.map (fun path -> path, Path.GetFileNameWithoutExtension path) 32 | |> Seq.toArray 33 | 34 | module Db = 35 | open LiteDB 36 | 37 | BsonMapper.Global.Entity().DbRef(fun f -> f.Files) |> ignore 38 | 39 | let dbPath = IO.userPathCombine $"{IO.AppName}.db" 40 | let newReadOnlyDb () = new LiteDatabase($"Filename=%s{dbPath}; Mode=ReadOnly") 41 | let newSharedDb () = new LiteDatabase($"Filename=%s{dbPath}; Mode=Shared") 42 | 43 | let findFolder (path: string) = 44 | use db = newReadOnlyDb () 45 | let doc = db.GetCollection().Include(fun f -> f.Files).FindById path 46 | if box doc <> null then Some doc else None 47 | 48 | let updateFile (file: File) = 49 | use db = newSharedDb () 50 | db.GetCollection().Update file |> ignore 51 | file 52 | 53 | let deleteFolder path = 54 | match findFolder path with 55 | | None -> () 56 | | Some folder -> 57 | use db = newSharedDb () 58 | for file in folder.Files do 59 | db.GetCollection().Delete file.Id |> ignore 60 | db.GetCollection().Delete folder.Id |> ignore 61 | 62 | let upsertFolder (folder: Folder) = 63 | deleteFolder folder.Id 64 | use db = newSharedDb () 65 | db.GetCollection().InsertBulk folder.Files |> ignore 66 | db.GetCollection().Insert folder |> ignore 67 | folder 68 | -------------------------------------------------------------------------------- /extLauncher/Program.fs: -------------------------------------------------------------------------------- 1 | namespace extLauncher 2 | 3 | open System 4 | open System.ComponentModel 5 | open Spectre.Console 6 | open Spectre.Console.Cli 7 | 8 | [] 9 | module private Implementations = 10 | open System.Diagnostics 11 | type Path = System.IO.Path 12 | 13 | let markup value = AnsiConsole.MarkupLine value 14 | 15 | let notInitialized () = 16 | markup $"Folder not yet indexed: [yellow]{IO.AppName}[/] index [gray]--help[/]" 17 | 1 18 | 19 | let run (file: File) launcher = 20 | markup $"""Launching [green]{file.Name}[/]...""" 21 | let file = file |> File.triggered |> Db.updateFile 22 | match launcher with 23 | | None -> 24 | let psi = ProcessStartInfo file.Id 25 | psi.UseShellExecute <- true 26 | Process.Start psi |> ignore 27 | | Some launcher -> 28 | let path = 29 | match launcher.Choose with 30 | | Choose.File -> file.Id 31 | | Choose.Directory -> Path.GetDirectoryName file.Id 32 | | _ -> NotImplementedException() |> raise 33 | let psi = ProcessStartInfo launcher.Path 34 | psi.Arguments <- Launcher.buildArgs launcher path 35 | Process.Start psi |> ignore 36 | 37 | let chooseLauncher folder file = 38 | match folder.Launchers with 39 | | [| |] -> 40 | run file None 41 | | [| launcher |] -> 42 | run file (Some launcher) 43 | | launchers -> 44 | Helpers.searchByName launchers 45 | |> Console.prompt Console.Terminal "With which launcher?" 10 46 | |> function 47 | | Some launcher -> run file (Some launcher) 48 | | None -> () 49 | 50 | let prompt folder = 51 | folder 52 | |> App.makeSearcher 53 | |> Console.prompt Console.Terminal "Search and launch:" 10 54 | |> Option.iter (chooseLauncher folder) 55 | 56 | let withLoader<'T> (worker: StatusContext -> 'T) = 57 | AnsiConsole.Status().Start("Indexing...", worker) 58 | 59 | let currentPath = 60 | Environment.CurrentDirectory 61 | 62 | let findFolder () = 63 | let rec find path = 64 | if isNull path then None else 65 | match Db.findFolder path with 66 | | Some f -> Some f 67 | | None -> find (Path.GetDirectoryName path) 68 | find currentPath 69 | 70 | let toCount str num = 71 | if num > 1 then $"{num} {str}s" else $"{num} {str}" 72 | 73 | let noNull s = if isNull s then "" else s 74 | 75 | let printLaunchers folder = 76 | let launchers = Table().AddColumns([| "Name"; "Choose"; "Path"; "Arguments" |]) 77 | launchers.Border <- TableBorder.Minimal 78 | for l in folder.Launchers do 79 | launchers.AddRow([| l.Name; string l.Choose; l.Path; noNull l.Arguments |]) |> ignore 80 | AnsiConsole.Write launchers 81 | 82 | type PromptCommand () = 83 | inherit Command () 84 | override _.Execute c = 85 | findFolder () 86 | |> Option.map (prompt >> fun () -> 0) 87 | |> Option.defaultWith notInitialized 88 | 89 | type IndexSettings () = 90 | inherit CommandSettings () 91 | [")>] 92 | [] 93 | member val Pattern = "" with get, set 94 | [] 95 | [] 96 | member val IsRegex = false with get, set 97 | 98 | type IndexCommand () = 99 | inherit Command () 100 | override _.Execute (_, settings) = 101 | fun _ -> 102 | App.index IO.getFiles Db.upsertFolder 103 | { Path = currentPath 104 | Pattern = Pattern.from settings.Pattern settings.IsRegex 105 | Launchers = Array.empty } 106 | |> withLoader 107 | |> function 108 | | Some folder -> 109 | printfn $"""{toCount "file" folder.Files.Length} indexed.""" 110 | markup $"Start to search and launch: [yellow]{IO.AppName}[/]" 111 | markup $"Add a specific launcher: [yellow]{IO.AppName}[/] launcher [gray]--help[/]" 112 | 0 113 | | None -> 114 | printfn $"{Console.NoMatch}" 115 | -1 116 | 117 | type LauncherSettings () = 118 | inherit CommandSettings () 119 | [")>] 120 | [] 121 | member val Name = "" with get, set 122 | 123 | type SetLauncherSettings () = 124 | inherit LauncherSettings () 125 | [")>] 126 | [] 127 | member val Path = "" with get, set 128 | [] 129 | [] 130 | member val Arguments = "" with get, set 131 | [] 132 | [] 133 | member val Choose = Choose.File with get, set 134 | 135 | type RemoveLauncherSettings () = 136 | inherit LauncherSettings () 137 | 138 | type SetLauncherCommand () = 139 | inherit Command () 140 | override _.Execute (_, settings) = 141 | match findFolder () with 142 | | None -> notInitialized () 143 | | Some folder -> 144 | markup $"[teal]{settings.Name}[/] launcher updated." 145 | { Name = settings.Name 146 | Path = settings.Path 147 | Arguments = settings.Arguments 148 | Choose = settings.Choose } 149 | |> fun launcher -> 150 | match folder.Launchers |> Array.tryFindIndex (fun l -> l.Name = launcher.Name) with 151 | | Some index -> 152 | folder.Launchers.[index] <- launcher 153 | folder 154 | | None -> 155 | { folder with Launchers = Array.insertAt 0 launcher folder.Launchers } 156 | |> Db.upsertFolder 157 | |> printLaunchers 158 | 0 159 | interface ICommandLimiter 160 | 161 | type RemoveLauncherCommand () = 162 | inherit Command () 163 | override _.Execute (_, settings) = 164 | match findFolder () with 165 | | None -> notInitialized () 166 | | Some folder -> 167 | match folder.Launchers |> Array.tryFindIndex (fun l -> l.Name = settings.Name) with 168 | | Some index -> 169 | markup $"[green]{settings.Name}[/] launcher removed." 170 | { folder with Launchers = Array.removeAt index folder.Launchers } 171 | |> Db.upsertFolder 172 | |> printLaunchers 173 | 0 174 | | None -> 175 | markup $"[green]{settings.Name}[/] launcher not found." 176 | printLaunchers folder 177 | 0 178 | interface ICommandLimiter 179 | 180 | type DeindexCommand () = 181 | inherit Command () 182 | override _.Execute _ = 183 | match Db.findFolder currentPath with 184 | | None -> notInitialized () 185 | | Some folder -> 186 | Db.deleteFolder folder.Id 187 | printfn "Deindexed" 188 | 0 189 | 190 | type InfoCommand () = 191 | inherit Command () 192 | override _.Execute _ = 193 | match findFolder () with 194 | | None -> notInitialized () 195 | | Some folder -> 196 | markup $"[teal]Path:[/]\n {folder.Id.EscapeMarkup()}" 197 | 198 | markup $"\n[teal]Pattern:[/]\n {folder.Pattern.EscapeMarkup()}" 199 | 200 | markup $"\n[teal]Launchers:[/]" 201 | if Array.isEmpty folder.Launchers 202 | then printfn " -\n" 203 | else printLaunchers folder 204 | 205 | markup $"[teal]Indexed files:[/]" 206 | let files = Table().AddColumns([| "Name"; "Triggered"; "Path" |]) 207 | files.Border <- TableBorder.Minimal 208 | for f in folder.Files do 209 | let path = f.Id.Remove(0, folder.Id.Length) 210 | files.AddRow([| f.Name; string f.Triggered; path |]) |> ignore 211 | AnsiConsole.Write files 212 | 213 | 0 214 | 215 | type RefreshCommand () = 216 | inherit Command () 217 | override _.Execute _ = 218 | match findFolder () with 219 | | None -> notInitialized () 220 | | Some folder -> 221 | fun _ -> 222 | folder 223 | |> App.refresh IO.getFiles Db.upsertFolder 224 | |> withLoader 225 | |> Option.iter prompt 226 | 0 227 | 228 | module Program = 229 | 230 | [] 231 | let main args = 232 | let app = CommandApp() 233 | app.Configure (fun conf -> 234 | conf.SetApplicationName(IO.AppName) |> ignore 235 | 236 | conf.AddCommand("prompt") 237 | .WithDescription("[italic](default command)[/] Type to search. Arrows Up/Down to navigate. Enter to launch. Escape to quit.") |> ignore 238 | 239 | conf.AddCommand("index") 240 | .WithDescription("Indexes all files recursively with a specific pattern which can be a wildcard [italic](default)[/] or a regular expression.") |> ignore 241 | 242 | conf.AddBranch("launcher", fun launcher -> 243 | launcher.SetDescription("Add, update or remove a launcher [italic](optional)[/].") 244 | launcher.AddCommand("set") 245 | .WithDescription("Add or update a launcher.") |> ignore 246 | launcher.AddCommand("remove") 247 | .WithDescription("Remove a launcher.") |> ignore 248 | ) |> ignore 249 | 250 | conf.AddCommand("deindex") 251 | .WithDescription("Clears the current index.") |> ignore 252 | 253 | conf.AddCommand("info") 254 | .WithDescription("Prints the current pattern and all the indexed files.") |> ignore 255 | 256 | conf.AddCommand("refresh") 257 | .WithDescription("Updates the current index.") |> ignore 258 | 259 | conf.AddExample([| "index"; "*.sln" |]) 260 | conf.AddExample([| "index"; "\"(.*)[.](fs|cs)proj$\""; "--regex" |]) 261 | conf.AddExample([| "launcher"; "mylauncher"; "set"; "execpath" |]) 262 | conf.AddExample([| "launcher"; "mylauncher"; "remove" |]) 263 | conf.AddExample([| "launcher"; "vscode"; "set"; "/usr/bin/code"; "--choose"; "file"; "--args=\"-r %s\"" |]) 264 | conf.AddExample([| "launcher"; "vscode"; "set"; @"""$env:LOCALAPPDATA\Programs\Microsoft VS Code\bin\code.cmd"""; "--choose"; "directory" |]) 265 | conf.AddExample([| "launcher"; "explorer"; "set"; "explorer.exe"; "--choose"; "directory" |]) 266 | 267 | #if DEBUG 268 | conf.ValidateExamples() |> ignore 269 | #endif 270 | ) 271 | app.Run args 272 | -------------------------------------------------------------------------------- /extLauncher/extLauncher.fsproj: -------------------------------------------------------------------------------- 1 |  2 | 3 | 4 | Exe 5 | netcoreapp3.1;net6.0 6 | preview 7 | true 8 | 9 | true 10 | extLauncher 11 | ./nupkg 12 | 13 | Copyright (c) 2022 D-EDGE 14 | Amin Khansari 15 | 16 | 17 | extLauncher 18 | DEdge;launcher;extLauncher 19 | https://github.com/d-edge/extLauncher/releases/ 20 | https://github.com/d-edge/extLauncher 21 | MIT 22 | logo-64x64.png 23 | true 24 | git 25 | https://github.com/d-edge/extLauncher 26 | 27 | README.md 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | true 40 | $(PackageIconUrl) 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | --------------------------------------------------------------------------------