Initial commit

This commit is contained in:
Joshua Higgins
2026-02-06 15:02:13 -05:00
commit 4980ef15a5
43 changed files with 5123 additions and 0 deletions

4
.eslintrc.json Normal file
View File

@@ -0,0 +1,4 @@
{
"root": true,
"extends": ["@raycast"]
}

10
.gitignore vendored Normal file
View File

@@ -0,0 +1,10 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
# Raycast specific files
raycast-env.d.ts
# misc
.DS_Store

4
.prettierrc Normal file
View File

@@ -0,0 +1,4 @@
{
"printWidth": 120,
"singleQuote": false
}

41
CHANGELOG.md Normal file
View File

@@ -0,0 +1,41 @@
# Yabai Changelog
## [Added search windows command] - 2025-02-25
- Adds "Focus Next Same App" command
- Focuses on the next window with the same application name
- MacOS has same feature, but it is only available in the current space
- Adds "Search Windows" command
- Searches for windows managed by Yabai
- Allows to search by window title or application name, it is really useful when windows have the same app name
## [Added window movement commands] - 2024-11-25
- Adds "Focus Active Window" commands
- "Focus Active Window North" moves window focus to the north.
- "Focus Active Window South" moves window focus to the south.
- "Focus Active Window East" moves window focus to the east.
- "Focus Active Window West" moves window focus to the west.
- Adds "Swap Active Window" commands
- "Swap Active Window North" swaps the active window with the window to the north.
- "Swap Active Window South" swaps the active window with the window to the south.
- "Swap Active Window East" swaps the active window with the window to the east.
- "Swap Active Window West" swaps the active window with the window to the west.
- Adds "Warp Active Window" commands
- "Warps Active Window North" warps the active window with the window to the north.
- "Warps Active Window South" warps the active window with the window to the south.
- "Warps Active Window East" warps the active window with the window to the east.
- "Warps Active Window West" warps the active window with the window to the west.
## [Added MenuBar to indicate the current screen and listed windows of the current screen] - 2024-07-05
## [Enhancements] - 2024-06-28
-- Adds "Restart" command.
-- Adds "Start" command.
-- Adds "Stop" command.
-- Adds "Layout BSP" command.
-- Adds "Layout Stack" command.
-- Adds "Layout Float" command.
## [Initial Version] - 2023-12-23

30
README.md Normal file
View File

@@ -0,0 +1,30 @@
# Yabai
> Run Yabai window management commands directly from Raycast.
## Installation
To use this extension, you must have `yabai` installed on your machine.
The easiest way to install this is using [Homebrew](https://brew.sh/). After you have Homebrew installed, run the
following command in your terminal:
```bash
brew install koekeishiya/formulae/yabai
```
[Official installation instructions](<https://github.com/koekeishiya/yabai/wiki/Installing-yabai-(latest-release)>)
## Additional Setup for MenuBar Desktop Indicator
To enable the desktop indicator in the MenuBar, you need to add a specific command to your yabairc configuration file.
Please add the following line to your yabairc file:
```bash
yabai -m signal --add event=space_changed action="nohup open -g raycast://extensions/krzysztoff1/yabai/screens-menu-bar?launchType=background > /dev/null 2>&1 &"
```
This command will allow the Raycast extension to run in the background and update the menubar indicator whenever you switch desktop spaces.
After adding this line, make sure to reload your yabairc configuration or restart yabai for the changes to take effect.

BIN
assets/yabai-icon-512px.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

3687
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

276
package.json Normal file
View File

@@ -0,0 +1,276 @@
{
"$schema": "https://www.raycast.com/schemas/extension.json",
"name": "yabai",
"title": "Yabai",
"description": "Control Yabai using Raycast. Requires yabai to be installed.",
"icon": "yabai-icon-512px.png",
"author": "krzysztoff1",
"contributors": [
"rubentsirunyan",
"webdesus",
"d34dh0r53",
"lhy-a"
],
"categories": [
"Developer Tools",
"Productivity"
],
"license": "MIT",
"commands": [
{
"name": "custom-yabai-command",
"title": "Run Custom Yabai Command",
"description": "Run custom Yabai command",
"mode": "no-view",
"arguments": [
{
"name": "command",
"placeholder": "Command",
"type": "text",
"required": false
}
]
},
{
"name": "screens-menu-bar",
"title": "Show Current Screen",
"description": "Update the status information about the currently selected screen.",
"mode": "menu-bar"
},
{
"name": "windows-list-current-space",
"title": "Windows List by Current Space",
"description": "Show the application of the current space.",
"mode": "view"
},
{
"name": "toggle",
"title": "Toggle Yabai",
"description": "This command will start Yabai using `yabai --start-service` or stop it using `yabai --stop-service`.",
"mode": "no-view"
},
{
"name": "rotate",
"title": "Rotate",
"description": "Rotate the tree 90 degrees clockwise.",
"mode": "no-view"
},
{
"name": "mirror-x",
"title": "Mirror X",
"description": "Mirror the tree on the X axis.",
"mode": "no-view"
},
{
"name": "mirror-y",
"title": "Mirror Y",
"description": "Mirror the tree on the Y axis.",
"mode": "no-view"
},
{
"name": "balance",
"title": "Balance",
"description": "Balance the windows.",
"mode": "no-view"
},
{
"name": "restart",
"title": "Restart",
"description": "Restart yabai service.",
"mode": "no-view"
},
{
"name": "start",
"title": "Start",
"description": "Start yabai service.",
"mode": "no-view"
},
{
"name": "stop",
"title": "Stop",
"description": "Stop yabai service.",
"mode": "no-view"
},
{
"name": "layout-bsp",
"title": "Layout BSP",
"description": "Switch to BSP layout for the current space.",
"mode": "no-view"
},
{
"name": "layout-stack",
"title": "Layout Stack",
"description": "Switch to stack layout for the current space.",
"mode": "no-view"
},
{
"name": "layout-float",
"title": "Layout Float",
"description": "Switch to float layout for the current space.",
"mode": "no-view"
},
{
"name": "create-space-and-focus",
"title": "Create & Focus New Space",
"subtitle": "Yabai",
"description": "Creates a new workspace and focuses it.",
"mode": "no-view"
},
{
"name": "focus-space",
"title": "Focus Space",
"subtitle": "Yabai",
"description": "Focuses the specified space.",
"mode": "no-view",
"arguments": [
{
"type": "text",
"placeholder": "Space Number",
"required": true,
"name": "spaceIndex"
}
]
},
{
"name": "destroy-space",
"title": "Destroy Current Space",
"subtitle": "Yabai",
"description": "Destroys the currently focused space.",
"mode": "no-view"
},
{
"name": "focus-window-north",
"title": "Focus Active Window North",
"subtitle": "Yabai",
"description": "Focus the window to the north of the currently focused window.",
"mode": "no-view"
},
{
"name": "focus-window-south",
"title": "Focus Active Window South",
"subtitle": "Yabai",
"description": "Focus the window to the south of the currently focused window.",
"mode": "no-view"
},
{
"name": "focus-window-east",
"title": "Focus Active Window East",
"subtitle": "Yabai",
"description": "Focus the window to the east of the currently focused window.",
"mode": "no-view"
},
{
"name": "focus-window-west",
"title": "Focus Active Window West",
"subtitle": "Yabai",
"description": "Focus the window to the west of the currently focused window.",
"mode": "no-view"
},
{
"name": "swap-window-north",
"title": "Swap Active Window North",
"subtitle": "Yabai",
"description": "Swap window position and size with window to the north of the active window.",
"mode": "no-view"
},
{
"name": "swap-window-south",
"title": "Swap Active Window South",
"subtitle": "Yabai",
"description": "Swap window position and size with window to the south of the active window.",
"mode": "no-view"
},
{
"name": "swap-window-east",
"title": "Swap Active Window East",
"subtitle": "Yabai",
"description": "Swap window position and size with window to the east of the active window.",
"mode": "no-view"
},
{
"name": "swap-window-west",
"title": "Swap Active Window West",
"subtitle": "Yabai",
"description": "Swap window position and size with window to the west of the active window.",
"mode": "no-view"
},
{
"name": "warp-window-north",
"title": "Warp Active Window North",
"subtitle": "Yabai",
"description": "Warp at window to the north of the focused window.",
"mode": "no-view"
},
{
"name": "warp-window-south",
"title": "Warp Active Window South",
"subtitle": "Yabai",
"description": "Warp at window to the south of the focused window.",
"mode": "no-view"
},
{
"name": "warp-window-east",
"title": "Warp Active Window East",
"subtitle": "Yabai",
"description": "Warp at window to the east of the focused window.",
"mode": "no-view"
},
{
"name": "warp-window-west",
"title": "Warp Active Window West",
"subtitle": "Yabai",
"description": "Warp at window to the west of the focused window.",
"mode": "no-view"
},
{
"name": "search-windows",
"title": "Search Windows",
"subtitle": "Yabai",
"description": "Search and focus Yabai managed windows",
"mode": "view",
"keywords": [
"s",
"window",
"search"
]
},
{
"name": "focus-next-same-app",
"title": "Focus Next Window of Same App",
"subtitle": "Yabai",
"description": "Focus the next window of the current application",
"mode": "no-view"
}
],
"preferences": [
{
"name": "yabaiPath",
"title": "Yabai installation path",
"required": false,
"type": "textfield",
"description": "Location to the Yabai installation (Defaults to `/opt/homebrew/bin/yabai` on M1 Macs, and `/usr/local/bin/yabai` otherwise)"
}
],
"dependencies": {
"@raycast/api": "^1.64.4",
"@raycast/utils": "^1.10.1",
"execa": "^8.0.1",
"ray": "^0.0.1",
"tiny-pinyin": "^1.3.2"
},
"devDependencies": {
"@raycast/eslint-config": "1.0.8",
"@types/node": "20.10.5",
"@types/react": "18.2.45",
"eslint": "^8.56.0",
"prettier": "^3.1.1",
"typescript": "^5.3.3"
},
"scripts": {
"build": "ray build -e dist",
"dev": "ray develop",
"fix-lint": "ray lint --fix",
"lint": "ray lint",
"publish": "npx @raycast/api@latest publish"
}
}

23
src/balance.ts Normal file
View File

@@ -0,0 +1,23 @@
import { showHUD } from "@raycast/api";
import { runYabaiCommand } from "./helpers/scripts";
import { showFailureToast } from "@raycast/utils";
export default async function Command() {
try {
const { stderr } = await runYabaiCommand("-m space --balance");
if (stderr) {
throw new Error();
}
showHUD("Balanced space");
} catch (error) {
if (error instanceof Error && error.message.includes("Yabai executable not found")) {
return;
}
showFailureToast(error, {
title: "Failed to balance space, make sure you have Yabai installed and running.",
});
}
}

View File

@@ -0,0 +1,35 @@
import { showHUD } from "@raycast/api";
import { runYabaiCommand } from "./helpers/scripts";
import { showFailureToast } from "@raycast/utils";
export default async function Command() {
try {
const { stderr } = await runYabaiCommand(`-m space --create`);
if (stderr) {
throw new Error(stderr);
}
const { stdout: spacesOutput } = await runYabaiCommand(`-m query --spaces`);
const spaces = JSON.parse(spacesOutput);
const lastSpaceIndex = spaces.filter((space: { [x: string]: never }) => !space["is-native-fullscreen"]).pop().index;
await runYabaiCommand(`-m space --focus ${lastSpaceIndex}`);
showHUD(`Created space: ${lastSpaceIndex}`);
} catch (error) {
if (error instanceof Error) {
if (error.message.includes("Yabai executable not found")) {
return;
}
showFailureToast(error, {
title: "Failed to create space",
});
} else {
showFailureToast(error, {
title: "Failed to create space",
});
}
}
}

View File

@@ -0,0 +1,35 @@
import { LaunchProps } from "@raycast/api";
import { runYabaiCommand } from "./helpers/scripts";
import { showFailureToast } from "@raycast/utils";
const prefixesToRemove = ["yabai -m", "yabai --message", "-m", "--message"];
export default async function Command(launchProps: LaunchProps) {
try {
let command = launchProps.arguments.command.trim().replace(/\s\s+/g, " ");
for (const prefix of prefixesToRemove) {
if (command.startsWith(prefix)) {
command = command.replace(prefix, "").trim();
}
}
if (!command) {
showFailureToast("No command provided.");
return;
}
command = `-m ${command}`;
const { stderr } = await runYabaiCommand(command);
if (stderr) {
throw new Error(stderr);
}
} catch (error) {
if (error instanceof Error && error.message.includes("Yabai executable not found")) {
return;
}
showFailureToast(error);
}
}

40
src/destroy-space.tsx Normal file
View File

@@ -0,0 +1,40 @@
import { showHUD } from "@raycast/api";
import { runYabaiCommand } from "./helpers/scripts";
import { showFailureToast } from "@raycast/utils";
export default async function Command() {
try {
const { stdout: rawRecentSpace, stderr } = await runYabaiCommand(`-m query --spaces --space recent`);
if (stderr) {
throw new Error(stderr);
}
const recentSpace = JSON.parse(rawRecentSpace);
const lastSpaceIndex = recentSpace.index;
await runYabaiCommand(`-m space --destroy`);
try {
await runYabaiCommand(`-m space --focus ${lastSpaceIndex}`);
} catch (error) {
throw new Error(`Failed to focus space: ${error}`);
}
await showHUD(`Destroyed Space`);
} catch (error) {
if (error instanceof Error) {
if (error.message.includes("Yabai executable not found")) {
return;
}
showFailureToast(error, {
title: "Failed to destroy space",
});
} else {
showFailureToast(error, {
title: "Failed to destroy space",
});
}
}
}

View File

@@ -0,0 +1,44 @@
import { closeMainWindow, showHUD } from "@raycast/api";
import { runYabaiCommand } from "./helpers/scripts";
import { sortWindows, BaseWindow } from "./helpers/window-utils";
interface Window extends BaseWindow {
"has-focus": boolean;
"is-minimized": boolean;
}
export default async function Command() {
try {
const { stdout: windowsJson } = await runYabaiCommand("-m query --windows");
const allWindows: Window[] = JSON.parse(windowsJson);
const sortedWindows = sortWindows(allWindows);
const currentWindow = sortedWindows.find((w) => w["has-focus"]);
if (!currentWindow) {
await showHUD("No focused window found");
return;
}
const sameAppWindows = sortedWindows.filter((w) => w.app === currentWindow.app);
if (sameAppWindows.length <= 1) {
await showHUD("No other windows of the same app");
return;
}
const currentIndex = sameAppWindows.findIndex((w) => w.id === currentWindow.id);
const nextIndex = (currentIndex + 1) % sameAppWindows.length;
const nextWindow = sameAppWindows[nextIndex];
await runYabaiCommand(`-m window --focus ${nextWindow.id}`);
if (nextWindow["is-minimized"]) {
await runYabaiCommand(`-m window ${nextWindow.id} --deminimize`);
}
await showHUD(`Focused ${nextWindow.app}`);
await closeMainWindow();
} catch (error) {
console.error("Error:", error);
await showHUD("Failed to focus next window");
}
}

39
src/focus-space.tsx Normal file
View File

@@ -0,0 +1,39 @@
import { LaunchProps, showHUD } from "@raycast/api";
import { runYabaiCommand } from "./helpers/scripts";
import { showFailureToast } from "@raycast/utils";
export default async function Command(args: LaunchProps) {
if (isNaN(args.arguments.spaceIndex)) {
showHUD(`Invalid space index: ${args.arguments.spaceIndex}`);
return;
}
const spaceIndex = parseInt(args.arguments.spaceIndex, 10);
await focusSpace(spaceIndex);
}
export async function focusSpace(spaceIndex: number) {
try {
const { stderr } = await runYabaiCommand(`-m space --focus ${spaceIndex}`);
if (stderr) {
throw new Error(stderr);
}
showHUD(`Focused space ${spaceIndex}`);
} catch (error) {
if (error instanceof Error) {
if (error.message.includes("already focused space")) {
return;
}
showFailureToast(error, {
title: "Failed to focus space",
});
return;
}
showFailureToast("Failed to focus space");
}
}

16
src/focus-window-east.ts Normal file
View File

@@ -0,0 +1,16 @@
import { runYabaiCommand } from "./helpers/scripts";
import { showFailureToast } from "@raycast/utils";
export default async function Command() {
try {
const { stderr } = await runYabaiCommand("-m window --focus east");
if (stderr) {
throw new Error(stderr);
}
} catch (error) {
showFailureToast(error, {
title: "Failed to warp window.",
});
}
}

16
src/focus-window-north.ts Normal file
View File

@@ -0,0 +1,16 @@
import { runYabaiCommand } from "./helpers/scripts";
import { showFailureToast } from "@raycast/utils";
export default async function Command() {
try {
const { stderr } = await runYabaiCommand("-m window --focus north");
if (stderr) {
throw new Error(stderr);
}
} catch (error) {
showFailureToast(error, {
title: "Failed to warp window.",
});
}
}

16
src/focus-window-south.ts Normal file
View File

@@ -0,0 +1,16 @@
import { runYabaiCommand } from "./helpers/scripts";
import { showFailureToast } from "@raycast/utils";
export default async function Command() {
try {
const { stderr } = await runYabaiCommand("-m window --focus south");
if (stderr) {
throw new Error(stderr);
}
} catch (error) {
showFailureToast(error, {
title: "Failed to warp window.",
});
}
}

16
src/focus-window-west.ts Normal file
View File

@@ -0,0 +1,16 @@
import { runYabaiCommand } from "./helpers/scripts";
import { showFailureToast } from "@raycast/utils";
export default async function Command() {
try {
const { stderr } = await runYabaiCommand("-m window --focus west");
if (stderr) {
throw new Error(stderr);
}
} catch (error) {
showFailureToast(error, {
title: "Failed to warp window.",
});
}
}

29
src/helpers/mirror.ts Normal file
View File

@@ -0,0 +1,29 @@
import { showHUD } from "@raycast/api";
import { showFailureToast } from "@raycast/utils";
import { runYabaiCommand } from "./scripts";
export async function mirror(axis: "x" | "y") {
const cmd = `-m space --mirror ${axis}-axis`;
try {
const { stderr } = await runYabaiCommand(cmd);
if (stderr) {
throw new Error(stderr);
}
showHUD(`Mirrored space in the ${axis} axis`);
} catch (error) {
if (
error instanceof Error &&
typeof error?.message === "string" &&
error.message.includes("Yabai executable not found")
) {
return;
}
showFailureToast(error, {
title: `Failed to mirror space in the ${axis} axis. Make sure Yabai is installed and running.`,
});
}
}

24
src/helpers/scripts.ts Normal file
View File

@@ -0,0 +1,24 @@
import { getPreferenceValues, showToast, Toast } from "@raycast/api";
import { execaCommand } from "execa";
import { userInfo } from "os";
import { cpus } from "os";
import fs from "fs";
const userEnv = `env USER=${userInfo().username}`;
export const runYabaiCommand = async (command: string, opt?: { shell?: boolean }) => {
const preferences = getPreferenceValues<Preferences>();
const yabaiPath: string =
preferences.yabaiPath && preferences.yabaiPath.length > 0
? preferences.yabaiPath
: cpus()[0].model.includes("Apple")
? "/opt/homebrew/bin/yabai"
: "/usr/local/bin/yabai";
if (!fs.existsSync(yabaiPath)) {
await showToast(Toast.Style.Failure, "Yabai executable not found", `Is yabai installed at ${yabaiPath}?`);
return { stdout: "", stderr: "Yabai executable not found" };
}
return await execaCommand([userEnv, yabaiPath, command].join(" "), opt);
};

View File

@@ -0,0 +1,42 @@
export interface BaseWindow {
id: number;
pid: number;
app: string;
title: string;
space: number;
frame: {
x: number;
y: number;
w: number;
h: number;
};
}
export function sortWindows<T extends BaseWindow>(windows: T[]): T[] {
return [...windows].sort((a, b) => {
if (a.space !== b.space) {
return a.space - b.space;
}
const aArea = {
min: { x: a.frame.x, y: a.frame.y },
max: { x: a.frame.x + a.frame.w, y: a.frame.y + a.frame.h },
};
const bArea = {
min: { x: b.frame.x, y: b.frame.y },
max: { x: b.frame.x + b.frame.w, y: b.frame.y + b.frame.h },
};
// calculate the overlap of the two windows
const overlapY = Math.max(0, Math.min(aArea.max.y, bArea.max.y) - Math.max(aArea.min.y, bArea.min.y));
const minHeight = Math.min(a.frame.h, b.frame.h);
// if the overlap of the two windows is greater than 50% of the window height, consider them in the same row
if (overlapY > minHeight * 0.5) {
return aArea.min.x - bArea.min.x;
}
// otherwise, sort by the top edge
return aArea.min.y - bArea.min.y;
});
}

23
src/layout-bsp.ts Normal file
View File

@@ -0,0 +1,23 @@
import { showHUD } from "@raycast/api";
import { runYabaiCommand } from "./helpers/scripts";
import { showFailureToast } from "@raycast/utils";
export default async function Command() {
try {
const { stderr } = await runYabaiCommand("-m space --layout bsp");
if (stderr) {
throw new Error();
}
showHUD("Switched to BSP layout");
} catch (error) {
if (error instanceof Error && error.message.includes("Yabai executable not found")) {
return;
}
showFailureToast(error, {
title: "Failed to switch to BSP layout, make sure you have Yabai installed and running.",
});
}
}

23
src/layout-float.ts Normal file
View File

@@ -0,0 +1,23 @@
import { showHUD } from "@raycast/api";
import { runYabaiCommand } from "./helpers/scripts";
import { showFailureToast } from "@raycast/utils";
export default async function Command() {
try {
const { stderr } = await runYabaiCommand("-m space --layout float");
if (stderr) {
throw new Error();
}
showHUD("Switched to float layout");
} catch (error) {
if (error instanceof Error && error.message.includes("Yabai executable not found")) {
return;
}
showFailureToast(error, {
title: "Failed to switch to float layout, make sure you have Yabai installed and running.",
});
}
}

23
src/layout-stack.ts Normal file
View File

@@ -0,0 +1,23 @@
import { showHUD } from "@raycast/api";
import { runYabaiCommand } from "./helpers/scripts";
import { showFailureToast } from "@raycast/utils";
export default async function Command() {
try {
const { stderr } = await runYabaiCommand("-m space --layout stack");
if (stderr) {
throw new Error();
}
showHUD("Switched to stack layout");
} catch (error) {
if (error instanceof Error && error.message.includes("Yabai executable not found")) {
return;
}
showFailureToast(error, {
title: "Failed to switch to stack layout, make sure you have Yabai installed and running.",
});
}
}

5
src/mirror-x.ts Normal file
View File

@@ -0,0 +1,5 @@
import { mirror } from "./helpers/mirror";
export default async function Command() {
await mirror("x");
}

5
src/mirror-y.ts Normal file
View File

@@ -0,0 +1,5 @@
import { mirror } from "./helpers/mirror";
export default async function Command() {
await mirror("y");
}

33
src/restart.ts Normal file
View File

@@ -0,0 +1,33 @@
import { showHUD } from "@raycast/api";
import { runYabaiCommand } from "./helpers/scripts";
import { showFailureToast } from "@raycast/utils";
export default async function Command() {
try {
const { stderr } = await runYabaiCommand("--restart-service");
if (stderr) {
throw new Error(stderr);
}
showHUD("Restarted yabai service");
} catch (error) {
try {
const { stderr: startStderr } = await runYabaiCommand("--start-service");
if (startStderr) {
throw new Error(startStderr);
}
showHUD("Yabai was not running. Started yabai service");
} catch (startError) {
if (startError instanceof Error && startError.message.includes("Yabai executable not found")) {
return;
}
showFailureToast(startError, {
title: "Failed to start yabai service, make sure you have Yabai installed.",
});
}
}
}

27
src/rotate.ts Normal file
View File

@@ -0,0 +1,27 @@
import { showHUD } from "@raycast/api";
import { runYabaiCommand } from "./helpers/scripts";
import { showFailureToast } from "@raycast/utils";
export default async () => {
try {
const { stderr } = await runYabaiCommand("-m space --rotate 90");
if (stderr) {
throw new Error(stderr);
}
showHUD("Rotated window tree");
} catch (error) {
if (
error instanceof Error &&
typeof error?.message === "string" &&
error.message.includes("Yabai executable not found")
) {
return;
}
showFailureToast(error, {
title: "Failed to rotate window tree, make sure you have Yabai installed and running.",
});
}
};

54
src/screens-menu-bar.tsx Normal file
View File

@@ -0,0 +1,54 @@
import { useEffect, useState } from "react";
import { Icon, MenuBarExtra } from "@raycast/api";
import { runYabaiCommand } from "./helpers/scripts";
import { focusSpace } from "./focus-space";
interface IDesktop {
index: number;
label: string;
"is-visible": boolean;
}
async function getDesktopList(): Promise<IDesktop[]> {
const desktopList = await runYabaiCommand("-m query --spaces --display");
if (desktopList.stdout) {
return JSON.parse(desktopList.stdout);
}
throw new Error(desktopList.stderr);
}
const useDesktop = () => {
const [state, setState] = useState<{ desktop: string; desktopList: IDesktop[]; isLoading: boolean }>({
desktop: "0",
desktopList: [],
isLoading: true,
});
useEffect(() => {
(async () => {
const desktopList = await getDesktopList();
const desktop = desktopList.filter((f) => f["is-visible"])[0];
setState({
desktop: desktop.label || desktop.index.toString(),
desktopList,
isLoading: false,
});
})();
}, []);
return state;
};
export default function Command() {
const { desktop, desktopList, isLoading } = useDesktop();
const icon = Icon.Desktop;
return (
<MenuBarExtra title={`${desktop}`} icon={icon} isLoading={isLoading}>
{desktopList?.map((item) => (
<MenuBarExtra.Item
key={item.index}
title={item.label || item.index.toString()}
onAction={() => focusSpace(item.index)}
/>
))}
</MenuBarExtra>
);
}

157
src/search-windows.tsx Normal file
View File

@@ -0,0 +1,157 @@
import { ActionPanel, Action, List, showToast, Toast } from "@raycast/api";
import { useState, useEffect, useMemo } from "react";
import { runYabaiCommand } from "./helpers/scripts";
import { execaCommand } from "execa";
import { sortWindows, BaseWindow } from "./helpers/window-utils";
import * as pinyin from "tiny-pinyin";
interface Window extends BaseWindow {
icon?: string;
}
async function findAppPath(pid: number): Promise<string> {
if (!Number.isInteger(pid) || pid <= 0) {
throw new Error("Invalid process ID");
}
const { stdout, stderr } = await execaCommand(`/usr/sbin/lsof -p ${pid} | grep txt | grep -v DEL | head -n 1 `, {
shell: true,
});
if (stderr) {
console.error(stderr);
return "";
}
const beginIndex = stdout.indexOf("/");
const appIndex = stdout.indexOf(".app");
if (appIndex === -1) {
return stdout;
}
return stdout.substring(beginIndex, appIndex + 4);
}
function getPinyin(text: string): string {
if (!/[\u4e00-\u9fa5]/.test(text)) {
return text.toLowerCase();
}
return pinyin.convertToPinyin(text, "", true).toLowerCase();
}
function timeLog(label: string, startTime: number) {
const endTime = performance.now();
console.debug(`${label}: ${endTime - startTime}ms`);
}
export default function Command() {
const [windows, setWindows] = useState<Window[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [filteredWindows, setFilteredWindows] = useState<Window[]>([]);
const [searchText, setSearchText] = useState("");
const filteredWindowsMemo = useMemo(() => {
const startTime = performance.now();
if (!searchText) {
timeLog("Filter (no search)", startTime);
return windows;
}
const lowerSearchText = searchText.toLowerCase();
const results = windows.filter((window) => {
const filterStartTime = performance.now();
if (window.title.toLowerCase().includes(lowerSearchText) || window.app.toLowerCase().includes(lowerSearchText)) {
timeLog("Filter (text match)", filterStartTime);
return true;
}
if (/^[a-z]+$/.test(lowerSearchText)) {
const pinyinStartTime = performance.now();
const titlePinyin = getPinyin(window.title);
const appPinyin = getPinyin(window.app);
const matched = titlePinyin.includes(lowerSearchText) || appPinyin.includes(lowerSearchText);
timeLog("Filter (pinyin match)", pinyinStartTime);
return matched;
}
timeLog("Filter (no match)", filterStartTime);
return false;
});
timeLog("Total filter time", startTime);
return results;
}, [windows, searchText]);
useEffect(() => {
async function fetchWindows() {
const startTime = performance.now();
try {
const { stdout, stderr } = await runYabaiCommand("-m query --windows");
if (stderr) {
throw new Error(stderr);
}
const windowsData: Window[] = JSON.parse(stdout);
const sortedWindows = sortWindows(windowsData);
timeLog("Fetch windows data", startTime);
const iconStartTime = performance.now();
const windowsWithIcons = await Promise.all(
sortedWindows.map(async (window) => ({
...window,
icon: await findAppPath(window.pid),
})),
);
timeLog("Add icons", iconStartTime);
setWindows(windowsWithIcons);
} catch (error) {
showToast(Toast.Style.Failure, "Failed to fetch windows");
} finally {
setIsLoading(false);
timeLog("Total fetch time", startTime);
}
}
fetchWindows();
}, []);
useEffect(() => {
setFilteredWindows(filteredWindowsMemo);
}, [filteredWindowsMemo]);
async function focusWindow(windowId: number) {
try {
const { stderr } = await runYabaiCommand(`-m window --focus ${windowId}`);
if (stderr) {
throw new Error(stderr);
}
showToast(Toast.Style.Success, "Window focused");
} catch (error) {
showToast(Toast.Style.Failure, "Failed to focus window");
}
}
return (
<List
isLoading={isLoading}
searchBarPlaceholder="Search windows by name or pinyin..."
onSearchTextChange={setSearchText}
>
{filteredWindows.map((window) => (
<List.Item
key={window.id}
icon={{ fileIcon: window.icon || window.app }}
title={window.app}
subtitle={window.title}
accessories={[{ text: `Space ${window.space}` }]}
actions={
<ActionPanel>
<Action title="Focus Window" onAction={() => focusWindow(window.id)} />
</ActionPanel>
}
/>
))}
</List>
);
}

23
src/start.ts Normal file
View File

@@ -0,0 +1,23 @@
import { showHUD } from "@raycast/api";
import { runYabaiCommand } from "./helpers/scripts";
import { showFailureToast } from "@raycast/utils";
export default async function Command() {
try {
const { stderr } = await runYabaiCommand("--start-service");
if (stderr) {
throw new Error();
}
showHUD("Started yabai service");
} catch (error) {
if (error instanceof Error && error.message.includes("Yabai executable not found")) {
return;
}
showFailureToast(error, {
title: "Failed to start yabai service, make sure you have Yabai installed.",
});
}
}

23
src/stop.ts Normal file
View File

@@ -0,0 +1,23 @@
import { showHUD } from "@raycast/api";
import { runYabaiCommand } from "./helpers/scripts";
import { showFailureToast } from "@raycast/utils";
export default async function Command() {
try {
const { stderr } = await runYabaiCommand("--stop-service");
if (stderr) {
throw new Error();
}
showHUD("Stopped yabai service");
} catch (error) {
if (error instanceof Error && error.message.includes("Yabai executable not found")) {
return;
}
showFailureToast(error, {
title: "Failed to stop yabai service, make sure you have Yabai installed and running.",
});
}
}

16
src/swap-window-east.ts Normal file
View File

@@ -0,0 +1,16 @@
import { runYabaiCommand } from "./helpers/scripts";
import { showFailureToast } from "@raycast/utils";
export default async function Command() {
try {
const { stderr } = await runYabaiCommand("-m window --swap east");
if (stderr) {
throw new Error(stderr);
}
} catch (error) {
showFailureToast(error, {
title: "Failed to swap window.",
});
}
}

16
src/swap-window-north.ts Normal file
View File

@@ -0,0 +1,16 @@
import { runYabaiCommand } from "./helpers/scripts";
import { showFailureToast } from "@raycast/utils";
export default async function Command() {
try {
const { stderr } = await runYabaiCommand("-m window --swap north");
if (stderr) {
throw new Error(stderr);
}
} catch (error) {
showFailureToast(error, {
title: "Failed to swap window.",
});
}
}

16
src/swap-window-south.ts Normal file
View File

@@ -0,0 +1,16 @@
import { runYabaiCommand } from "./helpers/scripts";
import { showFailureToast } from "@raycast/utils";
export default async function Command() {
try {
const { stderr } = await runYabaiCommand("-m window --swap south");
if (stderr) {
throw new Error(stderr);
}
} catch (error) {
showFailureToast(error, {
title: "Failed to swap window.",
});
}
}

16
src/swap-window-west.ts Normal file
View File

@@ -0,0 +1,16 @@
import { runYabaiCommand } from "./helpers/scripts";
import { showFailureToast } from "@raycast/utils";
export default async function Command() {
try {
const { stderr } = await runYabaiCommand("-m window --swap west");
if (stderr) {
throw new Error(stderr);
}
} catch (error) {
showFailureToast(error, {
title: "Failed to swap window.",
});
}
}

51
src/toggle.ts Normal file
View File

@@ -0,0 +1,51 @@
import { showHUD } from "@raycast/api";
import { runYabaiCommand } from "./helpers/scripts";
import { showFailureToast } from "@raycast/utils";
export default async () => {
let serviceRunning = false;
try {
const { stderr } = await runYabaiCommand("--stop-service");
if (stderr) {
throw new Error(stderr);
}
showHUD("Yabai has been stopped.");
serviceRunning = false;
} catch (error) {
if (error instanceof Error && error.message.includes("Yabai executable not found")) {
return;
}
if (String(error).includes("Could not find service")) {
serviceRunning = true;
} else {
showFailureToast(error, {
title: "Error stopping Yabai",
});
}
}
if (serviceRunning) {
try {
const { stderr } = await runYabaiCommand("--start-service");
if (stderr) {
throw new Error(stderr);
}
showHUD("Yabai has been started.");
} catch (error) {
if (error instanceof Error && error.message.includes("Yabai executable not found")) {
return;
}
showFailureToast(error, {
title: "Error starting Yabai",
});
}
}
};

16
src/warp-window-east.ts Normal file
View File

@@ -0,0 +1,16 @@
import { runYabaiCommand } from "./helpers/scripts";
import { showFailureToast } from "@raycast/utils";
export default async function Command() {
try {
const { stderr } = await runYabaiCommand("-m window --warp east");
if (stderr) {
throw new Error(stderr);
}
} catch (error) {
showFailureToast(error, {
title: "Failed to warp window.",
});
}
}

16
src/warp-window-north.ts Normal file
View File

@@ -0,0 +1,16 @@
import { runYabaiCommand } from "./helpers/scripts";
import { showFailureToast } from "@raycast/utils";
export default async function Command() {
try {
const { stderr } = await runYabaiCommand("-m window --warp north");
if (stderr) {
throw new Error(stderr);
}
} catch (error) {
showFailureToast(error, {
title: "Failed to warp window.",
});
}
}

16
src/warp-window-south.ts Normal file
View File

@@ -0,0 +1,16 @@
import { runYabaiCommand } from "./helpers/scripts";
import { showFailureToast } from "@raycast/utils";
export default async function Command() {
try {
const { stderr } = await runYabaiCommand("-m window --warp south");
if (stderr) {
throw new Error(stderr);
}
} catch (error) {
showFailureToast(error, {
title: "Failed to warp window.",
});
}
}

16
src/warp-window-west.ts Normal file
View File

@@ -0,0 +1,16 @@
import { runYabaiCommand } from "./helpers/scripts";
import { showFailureToast } from "@raycast/utils";
export default async function Command() {
try {
const { stderr } = await runYabaiCommand("-m window --warp west");
if (stderr) {
throw new Error(stderr);
}
} catch (error) {
showFailureToast(error, {
title: "Failed to warp window.",
});
}
}

View File

@@ -0,0 +1,104 @@
import { useEffect, useState } from "react";
import { Action, ActionPanel, List, PopToRootType, closeMainWindow } from "@raycast/api";
import { runYabaiCommand } from "./helpers/scripts";
import { execaCommand } from "execa";
interface IWindow {
id: number;
pid: number;
title: string;
icon: string;
app: string;
"has-focus": boolean;
"stack-index": number;
}
async function getWindowsList(): Promise<IWindow[]> {
const windowsList = await runYabaiCommand(`-m query --windows --space`);
if (windowsList.stdout) {
return JSON.parse(windowsList.stdout);
}
throw new Error(windowsList.stderr);
}
const useWindowsList = () => {
const [state, setState] = useState<{ list: IWindow[]; isLoading: boolean }>({
list: [],
isLoading: true,
});
useEffect(() => {
(async () => {
try {
const list = await getWindowsList();
list.sort((a, b) => a["stack-index"] - b["stack-index"]);
setState({
list: await Promise.all(
list.map(async (el) => {
el.icon = (await findAppPath(el.pid)) || "";
el.title = el.title || el.app;
return el;
}),
),
isLoading: false,
});
} catch (error) {
console.error(error);
setState({
list: [],
isLoading: false,
});
}
})();
}, []);
return state;
};
async function findAppPath(pid: number): Promise<string> {
const { stdout, stderr } = await execaCommand(`/usr/sbin/lsof -p ${pid} | grep txt | grep -v DEL | head -n 1 `, {
shell: true,
});
if (stderr) {
console.error(stderr);
return "";
}
const beginIndex = stdout.indexOf("/");
const appIndex = stdout.indexOf(".app");
if (appIndex === -1) {
return stdout;
}
return stdout.substring(beginIndex, appIndex + 4);
}
export function selectWindow(id: number) {
runYabaiCommand(`-m window --focus ${id}`);
closeMainWindow({ clearRootSearch: true, popToRootType: PopToRootType.Immediate });
}
export default function Command() {
const { list, isLoading } = useWindowsList();
let selectedItemId = 0;
if (list) {
selectedItemId = list.find((f) => f["has-focus"])?.id || 0;
}
return (
<List isLoading={isLoading} selectedItemId={selectedItemId.toString()}>
{list?.map((item) => (
<List.Item
id={item.id.toString()}
key={item.id}
icon={{ fileIcon: item.icon }}
title={item.title}
accessories={[{ text: item.app }]}
actions={
<ActionPanel>
<ActionPanel.Section>
<Action title="Focus Window" onAction={() => selectWindow(item.id)} />
</ActionPanel.Section>
</ActionPanel>
}
/>
))}
</List>
);
}

17
tsconfig.json Normal file
View File

@@ -0,0 +1,17 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"display": "Node 16",
"include": ["src/**/*", "raycast-env.d.ts"],
"compilerOptions": {
"lib": ["es2021"],
"module": "commonjs",
"target": "es2021",
"strict": true,
"isolatedModules": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"jsx": "react-jsx",
"resolveJsonModule": true
}
}