Show folder content in custom context menu

As the title explains, is it possible to show the content of a specific folder in a custom menu? I want to create a custom app launcher that lists all apps with a specific tag. Thanks!

You can do that, it requires some scripting.

Here is an example script that you can modify. In this example it lists all files with tags Red or Green from ~/Documents.

async function retrieveJSON() {
// define your folder an tags here:
  let folder = "~/Documents";
  let tags = ["Red", "Green"];
  
  // Join tags to form the search query, each tag must be checked separately with an OR condition
  let tagQuery = tags.map(tag => `kMDItemUserTags == '${tag}'`).join(" || ");
  
  // Construct the shell script with the folder and tags
  let script = `mdfind -onlyin ${folder} "${tagQuery}"`;

  let filePaths = await runShellScript({
    script: script,
  });

  // Split the file paths by new line
  let fileArray = filePaths.split("\n").filter(Boolean); // filter(Boolean) removes any empty lines

  // Create an array of objects with filePath and fileName
  let files = fileArray.map((filePath) => {
    let fileName = filePath.split("/").pop(); // Extract the file name from the path
    return { filePath, fileName };
  });

  let menuItems = [];

  // Create the context menu items that trigger a shell script to open the file
  for (let file of files) {
    let item = {
      title: file.fileName,
      action: `js::(async () => {runShellScript({script: 'open "${file.filePath}"'})})()`,
    };
    menuItems.push(item);
  }

  return JSON.stringify(menuItems);
}

You need to use this script within the "Custom Context Menu (New) action:

1 Like

And here is a slightly improved version of the script that also shows the app icon when the file is an app. This requires the latest BTT alpha 4.717 though - with previous versions the icons would be huge.

image

async function retrieveJSON() {
  let folder = "/Applications";
  let tags = ["Red", "Green"];
  
  // Join tags to form the search query, each tag must be checked separately with an OR condition
  let tagQuery = tags.map(tag => `kMDItemUserTags == '${tag}'`).join(" || ");
  
  // Construct the shell script with the folder and tags
  let script = `mdfind -onlyin ${folder} "${tagQuery}"`;

  let filePaths = await runShellScript({
    script: script,
  });

  // Split the file paths by new line
  let fileArray = filePaths.split("\n").filter(Boolean); // filter(Boolean) removes any empty lines

  // Create an array of objects with filePath and fileName
  let files = fileArray.map((filePath) => {
    let fileName = filePath.split("/").pop(); // Extract the file name from the path
    return { filePath, fileName };
  });

  let menuItems = [];

  // Function to retrieve the icon path by reading the Info.plist
  async function getAppIconPath(filePath) {
    // Construct the path to the Info.plist file
    let plistPath = `${filePath}/Contents/Info.plist`;

    // Run a shell script to retrieve the CFBundleIconFile from the Info.plist
    let iconName = await runShellScript({
      script: `defaults read "${plistPath}" CFBundleIconFile || echo "AppIcon"`,
    });

    // Add the .icns extension if not present
    iconName = iconName.trim();
    if (!iconName.endsWith(".icns")) {
      iconName += ".icns";
    }

    // Return the full path to the .icns file
    return `${filePath}/Contents/Resources/${iconName}`;
  }

  // Create the context menu items that trigger a shell script to open the file
  for (let file of files) {
    let iconPath = await getAppIconPath(file.filePath); // Get the correct icon path from Info.plist
    let item = {
      title: file.fileName,
      action: `js::(async () => {runShellScript({script: 'open "${file.filePath}"'})})()`,
      icon: `path::${iconPath}::width@@30`
    };
    menuItems.push(item);
  }

  return JSON.stringify(menuItems);
}

Instead of a context menu, you can also use this script with the "Choose From List" action:

wow, works great. thanks!!

1 Like

Hi Andreas,

Thanks for showing this inspiring application of BTT.

What I want to achieve: when I click on a menu item in the floating menu, it show the list of files in a default folder, and I can open the file, with its default app, by clicking on the filename on the list.

I tried to start the exploration by adding the action "Show Custom Context Menu(NEW)" to a menu item (with your script) in a floating menu and hope that I will be able to see the list of files to begin with.

When I tested the menu by using "Run script", I only get a blank rectangle.


Thank you in advance.

@ngan this script will only list files that have the tag "Red" or "Green". It sounds like you'd rather have just all files regardless of the tag, correct?

Yes, I would like to display all files (no sub folder) in the folder.
Thanks for reminding that there is a filter for tag in the script!
I can live with the existing script, the workaround is to assign a dummy tag to the files that I want to show in the list.

I just tested the workaround, and it works beautifully!

Thanks again.

You should probably remove the icon retrieval because that only makes sense for apps and slows things down quite a bit:

async function retrieveJSON() {
  let folder = "~/Documents";

  // Construct the shell script to search for all files in the folder
  let script = `mdfind -onlyin ${folder} "*"`;

  let filePaths = await runShellScript({
    script: script,
  });
  // Split the file paths by new line
  let fileArray = filePaths.split("\n").filter(Boolean); // filter(Boolean) removes any empty lines

  // Create an array of objects with filePath and fileName
  let files = fileArray.map((filePath) => {
    let fileName = filePath.split("/").pop(); // Extract the file name from the path
    return { filePath, fileName };
  });

  let menuItems = [];


  // Create the context menu items that trigger a shell script to open the file
  for (let file of files) {
    let item = {
      title: file.fileName,
      action: `js::(async () => {runShellScript({script: 'open "${file.filePath}"'})})()`,
      icon: `sfsymbol::file`
    };
    menuItems.push(item);
  }

  return JSON.stringify(menuItems);
}

Thankyou again.

For the search criteria, can it only search files that is not in sub-folders? The current seach script lists all files, including the files in all sub-folders.

let script = mdfind -onlyin ${folder} "*";

Yes you can set the depth:

mdfind -onlyin ${folder} -depth 1 "*"

Thanks for the help.
It seems depth is not supported by mdfind. I use the -depth 1 opion and get the error code instead of the list of file. It's ok, I am happy with the workaround by using a dummy tag.

Ah you are right, sorry I remembered that incorrectly.

You could however switch to a different command like ls for simple lists of files:

async function retrieveJSON() {
    let folder = "/Users/andreas/Documents";
  
    // Construct the shell script to recursively list all files using ls
    let script = `ls "${folder}"`;
  
    let filePaths = await runShellScript({
      script: script,
    });
  
    // Split the file paths by new line
    let fileArray = filePaths.split("\n").filter(Boolean); // filter(Boolean) removes any empty lines
  
    // Create an array of objects with filePath and fileName
    let files = fileArray.map((filePath) => {
      let fileName = filePath.split("/").pop(); // Extract the file name from the path
      return { filePath, fileName };
    });
  
    let menuItems = [];
  
  
    // Create the context menu items that trigger a shell script to open the file
    for (let file of files) {
      let item = {
        title: file.fileName,
        action: `js::(async () => {runShellScript({script: 'open "${folder + '/' + file.filePath}"'})})()`,
        icon: `sfsymbol::doc.circle`
      };
      menuItems.push(item);
    }
  
    return JSON.stringify(menuItems);
  }
  

(by the way, one of the next versions will also allow to use these scripts in floating menus to dynamically retrieve their items)

This script still shows all files including those in subfolders.
This is not urgent. The workaround of attacing a dummy tag is workable for me for now. I look forward to seeing the next versions with such build in function.
Thanks for developing this wonderful utilities app.

the ls command only lists the files from the given folder by default, are you sure the script has updated correctly? Possibly restart BTT

Thanks again! Your script works and I misunderstood what I saw.

Upon rechecking, I found that all "extra" files in the list have "~$" as prefix. They probably are previously deleted excel files and I mistook them as files in subfolders.

After I used "Command + Shift + ." to unhide and delete those files, the list is now showing only the name of subfolders and the files in the main folder.

Further googling suggests that Microsoft apps leave hidden files with "~$" in MacOS as prefix after they are deleted or for some unknown reasons. One being mentioned " ~$ files generated by Microsoft Office (i.e., Word, Excel, etc.) are usually erased after the file has been closed. In some cases, like when files are saved on a cloud/online disk, the latter can "throw back" to the local disk those files, hence the question."

You can probably filter out such files using something like

ls | grep -v '^~\$'