Skip to content

Commit 5eb7b0d

Browse files
authored
Merge pull request #9459 from gitbutlerapp/feature-workspace-rules-model
Experimental: DIsplay rules in the unassigned drawer
2 parents e9b0597 + 4e4e202 commit 5eb7b0d

File tree

9 files changed

+282
-4
lines changed

9 files changed

+282
-4
lines changed
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
<script lang="ts">
2+
import ReduxResult from '$components/ReduxResult.svelte';
3+
import RulesService from '$lib/rules/rulesService.svelte';
4+
import { getContext } from '@gitbutler/shared/context';
5+
import Button from '@gitbutler/ui/Button.svelte';
6+
7+
type Props = {
8+
projectId: string;
9+
};
10+
11+
const { projectId }: Props = $props();
12+
13+
const rulesService = getContext(RulesService);
14+
const [_, creatingRule] = rulesService.createWorkspaceRule;
15+
16+
const rules = $derived(rulesService.listWorkspaceRules(projectId));
17+
</script>
18+
19+
<div class="rules-list">
20+
<ReduxResult {projectId} result={rules.current}>
21+
{#snippet children(rules)}
22+
<div>
23+
{#each rules as rule (rule.id)}
24+
<pre class="text-12">
25+
{JSON.stringify(rules, null, 2)}
26+
</pre>
27+
{:else}
28+
<p class="text-12 text-grey">No rules found.</p>
29+
{/each}
30+
</div>
31+
{/snippet}
32+
</ReduxResult>
33+
<Button
34+
wide
35+
icon="plus-small"
36+
kind="outline"
37+
disabled
38+
tooltip="Not implemented yet"
39+
loading={creatingRule.current.isLoading}>Add new rule</Button
40+
>
41+
</div>
42+
43+
<style lang="postcss">
44+
.rules-list {
45+
display: flex;
46+
flex-direction: column;
47+
padding: 12px;
48+
gap: 8px;
49+
}
50+
51+
.text-grey {
52+
color: var(--clr-text-2);
53+
}
54+
</style>

apps/desktop/src/components/UnassignedView.svelte

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
11
<script lang="ts">
2+
import RulesList from '$components/RulesList.svelte';
23
import UnassignedFoldButton from '$components/UnassignedFoldButton.svelte';
34
import WorktreeChanges from '$components/WorktreeChanges.svelte';
45
import WorktreeTipsFooter from '$components/WorktreeTipsFooter.svelte';
56
import noChanges from '$lib/assets/illustrations/no-changes.svg?raw';
7+
import { workspaceRulesEnabled } from '$lib/config/uiFeatureFlags';
68
import { DefinedFocusable } from '$lib/focus/focusManager.svelte';
79
import { IntelligentScrollingService } from '$lib/intelligentScrolling/service';
810
import { UncommittedService } from '$lib/selection/uncommittedService.svelte';
@@ -103,6 +105,10 @@
103105
</Button>
104106
</div>
105107
{/if}
108+
109+
{#if $workspaceRulesEnabled}
110+
<RulesList {projectId} />
111+
{/if}
106112
</div>
107113
{:else}
108114
<div

apps/desktop/src/components/profileSettings/ExperimentalSettings.svelte

Lines changed: 20 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
<script lang="ts">
22
import { SettingsService } from '$lib/config/appSettingsV2';
3-
import { ircEnabled, ircServer } from '$lib/config/uiFeatureFlags';
3+
import { ircEnabled, ircServer, workspaceRulesEnabled } from '$lib/config/uiFeatureFlags';
44
import { User } from '$lib/user/user';
55
import { getContext, getContextStore } from '@gitbutler/shared/context';
66
import SectionCard from '@gitbutler/ui/SectionCard.svelte';
@@ -79,11 +79,28 @@
7979
{/if}
8080

8181
<SectionCard
82-
labelFor="irc"
82+
labelFor="rules"
8383
roundedTop={!$settingsStore?.featureFlags.actions}
84-
roundedBottom={!$ircEnabled}
84+
roundedBottom={false}
8585
orientation="row"
8686
>
87+
{#snippet title()}
88+
Workspace Rules
89+
{/snippet}
90+
{#snippet caption()}
91+
Go full dominatrix on your workspace and add a bunch rules that can automatically trigger
92+
actions.
93+
{/snippet}
94+
{#snippet actions()}
95+
<Toggle
96+
id="rules"
97+
checked={$workspaceRulesEnabled}
98+
onclick={() => workspaceRulesEnabled.set(!$workspaceRulesEnabled)}
99+
/>
100+
{/snippet}
101+
</SectionCard>
102+
103+
<SectionCard labelFor="irc" roundedTop={false} roundedBottom={!$ircEnabled} orientation="row">
87104
{#snippet title()}
88105
IRC
89106
{/snippet}
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export default class BackendService {}

apps/desktop/src/lib/config/uiFeatureFlags.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,3 +10,4 @@ export const autoSelectBranchNameFeature = persisted(false, 'autoSelectBranchLan
1010
export const ircEnabled = persistWithExpiration(false, 'feature-irc', 1440 * 30);
1111
export const ircServer = persistWithExpiration('', 'feature-irc-server', 1440 * 30);
1212
export const rewrapCommitMessage = persistWithExpiration(true, 'rewrap-commit-msg', 1440 * 30);
13+
export const workspaceRulesEnabled = persisted(false, 'workspace-rules-enabled');

apps/desktop/src/lib/rules/rule.ts

Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
import type { BrandedId } from '@gitbutler/shared/utils/branding';
2+
3+
export type WorkspaceRuleId = BrandedId<'WorkspaceRule'>;
4+
/**
5+
* A workspace rule.
6+
* @remarks
7+
* A rule is evaluated in the app and determines what happens to files or changes based on triggers, filters, and actions.
8+
*/
9+
export interface WorkspaceRule {
10+
/** A UUID unique identifier for the rule. */
11+
id: WorkspaceRuleId;
12+
/** The time when the rule was created, represented as a Unix timestamp in milliseconds. */
13+
createdAt: string; // ISO string or Date, depending on backend serialization
14+
/** Whether the rule is currently enabled or not. */
15+
enabled: boolean;
16+
/** The trigger of the rule is what causes it to be evaluated in the app. */
17+
trigger: Trigger;
18+
/** These filters determine what files or changes the rule applies to. Multiple filters are combined with OR logic. */
19+
filters: Filter[];
20+
/** The action determines what happens to the files or changes that matched the filters. */
21+
action: Action;
22+
}
23+
24+
/**
25+
* Represents the kinds of events in the app that can cause a rule to be evaluated.
26+
*/
27+
export type Trigger =
28+
/** When a file is added, removed or modified in the Git worktree. */
29+
'fileSytemChange';
30+
31+
/**
32+
* A filter is a condition that determines what files or changes the rule applies to.
33+
* Multiple conditions in a filter are combined with AND logic.
34+
*/
35+
export type Filter =
36+
| { type: 'pathMatchesRegex'; subject: string[] } // regex patterns as strings
37+
| { type: 'contentMatchesRegex'; subject: string[] } // regex patterns as strings
38+
| { type: 'fileChangeType'; subject: TreeStatus }
39+
| { type: 'semanticType'; subject: SemanticType };
40+
41+
/**
42+
* Represents the type of change that occurred in the Git worktree.
43+
* Matches the TreeStatus of the TreeChange.
44+
*/
45+
export type TreeStatus = 'addition' | 'deletion' | 'modification' | 'rename';
46+
47+
/**
48+
* Represents a semantic type of change that was inferred for the change.
49+
* Typically this means a heuristic or an LLM determined that a change represents a refactor, a new feature, a bug fix, or documentation update.
50+
*/
51+
export type SemanticType =
52+
| { type: 'refactor' }
53+
| { type: 'newFeature' }
54+
| { type: 'bugFix' }
55+
| { type: 'documentation' }
56+
| { type: 'userDefined'; subject: string };
57+
58+
/**
59+
* Represents an action that can be taken based on the rule evaluation.
60+
* An action can be either explicit (user defined) or implicit (determined by heuristics or AI).
61+
*/
62+
export type Action =
63+
| { type: 'explicit'; subject: Operation }
64+
| { type: 'implicit'; subject: ImplicitOperation };
65+
66+
/**
67+
* Represents the operation that a user can configure to be performed in an explicit action.
68+
*/
69+
export type Operation =
70+
| { type: 'assign'; stackId: string }
71+
| { type: 'amend'; commitId: string }
72+
| { type: 'newCommit'; branchName: string };
73+
74+
/**
75+
* Represents the implicit operation that is determined by heuristics or AI.
76+
*/
77+
export type ImplicitOperation =
78+
| { type: 'assignToAppropriateBranch' }
79+
| { type: 'absorbIntoDependentCommit' }
80+
| { type: 'llmPrompt'; subject: string };
81+
82+
/**
83+
* A request to create a new workspace rule.
84+
*/
85+
export interface CreateRuleRequest {
86+
/** The trigger that causes the rule to be evaluated. */
87+
trigger: Trigger;
88+
/** The filters that determine what files or changes the rule applies to. Cannot be empty. */
89+
filters: Filter[];
90+
/** The action that determines what happens to the files or changes that matched the filters. */
91+
action: Action;
92+
}
93+
94+
/**
95+
* A request to update an existing workspace rule.
96+
*/
97+
export interface UpdateRuleRequest {
98+
/** The ID of the rule to update. */
99+
id: WorkspaceRuleId;
100+
/** The new enabled state of the rule. If not provided, the existing state is retained. */
101+
enabled: boolean | null;
102+
/** The new trigger for the rule. If not provided, the existing trigger is retained. */
103+
trigger: Trigger | null;
104+
/** The new filters for the rule. If not provided, the existing filters are retained. */
105+
filters: Filter[] | null;
106+
/** The new action for the rule. If not provided, the existing action is retained. */
107+
action: Action | null;
108+
}
Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
import { invalidatesItem, invalidatesList, providesItems, ReduxTag } from '$lib/state/tags';
2+
import { createEntityAdapter, type EntityState } from '@reduxjs/toolkit';
3+
import type {
4+
CreateRuleRequest,
5+
UpdateRuleRequest,
6+
WorkspaceRule,
7+
WorkspaceRuleId
8+
} from '$lib/rules/rule';
9+
import type { BackendApi } from '$lib/state/clientState.svelte';
10+
11+
export default class RulesService {
12+
private api: ReturnType<typeof injectEndpoints>;
13+
14+
constructor(backendApi: BackendApi) {
15+
this.api = injectEndpoints(backendApi);
16+
}
17+
18+
get createWorkspaceRule() {
19+
return this.api.endpoints.createWorkspaceRule.useMutation();
20+
}
21+
22+
get deleteWorkspaceRule() {
23+
return this.api.endpoints.deleteWorkspaceRule.useMutation();
24+
}
25+
26+
get updateWorkspaceRule() {
27+
return this.api.endpoints.updateWorkspaceRule.useMutation();
28+
}
29+
30+
listWorkspaceRules(projectId: string) {
31+
return this.api.endpoints.listWorkspaceRules.useQuery(
32+
{ projectId },
33+
{ transform: (result) => workspaceRulesSelectors.selectAll(result) }
34+
);
35+
}
36+
}
37+
38+
function injectEndpoints(api: BackendApi) {
39+
return api.injectEndpoints({
40+
endpoints: (build) => ({
41+
createWorkspaceRule: build.mutation<
42+
WorkspaceRule,
43+
{ projectId: string; request: CreateRuleRequest }
44+
>({
45+
extraOptions: { command: 'create_workspace_rule' },
46+
query: (args) => args,
47+
invalidatesTags: () => [invalidatesList(ReduxTag.WorkspaceRules)]
48+
}),
49+
deleteWorkspaceRule: build.mutation<void, { projectId: string; ruleId: WorkspaceRuleId }>({
50+
extraOptions: { command: 'delete_workspace_rule' },
51+
query: (args) => args,
52+
invalidatesTags: () => [invalidatesList(ReduxTag.WorkspaceRules)]
53+
}),
54+
updateWorkspaceRule: build.mutation<
55+
WorkspaceRule,
56+
{ projectId: string; request: UpdateRuleRequest }
57+
>({
58+
extraOptions: { command: 'update_workspace_rule' },
59+
query: (args) => args,
60+
invalidatesTags: (result) =>
61+
result
62+
? [
63+
invalidatesItem(ReduxTag.WorkspaceRules, result.id),
64+
invalidatesList(ReduxTag.WorkspaceRules)
65+
]
66+
: []
67+
}),
68+
listWorkspaceRules: build.query<
69+
EntityState<WorkspaceRule, WorkspaceRuleId>,
70+
{ projectId: string }
71+
>({
72+
extraOptions: { command: 'list_workspace_rules' },
73+
query: (args) => args,
74+
providesTags: (result) => providesItems(ReduxTag.WorkspaceRules, result?.ids ?? []),
75+
transformResponse: (response: WorkspaceRule[]) => {
76+
return workspaceRulesAdapter.addMany(workspaceRulesAdapter.getInitialState(), response);
77+
}
78+
})
79+
})
80+
});
81+
}
82+
83+
const workspaceRulesAdapter = createEntityAdapter<WorkspaceRule, WorkspaceRuleId>({
84+
selectId: (rule) => rule.id
85+
});
86+
87+
const workspaceRulesSelectors = workspaceRulesAdapter.getSelectors();

apps/desktop/src/lib/state/tags.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,8 @@ export enum ReduxTag {
2020
UpstreamIntegrationStatus = 'UpstreamIntegrationStatus',
2121
BranchListing = 'BranchListing',
2222
BranchDetails = 'BranchDetails',
23-
SnapshotDiff = 'SnapshotDiff'
23+
SnapshotDiff = 'SnapshotDiff',
24+
WorkspaceRules = 'WorkspaceRules'
2425
}
2526

2627
type Tag<T extends string | number> = {

apps/desktop/src/routes/+layout.svelte

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@
5050
import { ProjectsService } from '$lib/project/projectsService';
5151
import { PromptService } from '$lib/prompt/promptService';
5252
import { RemotesService } from '$lib/remotes/remotesService';
53+
import RulesService from '$lib/rules/rulesService.svelte';
5354
import { setSecretsService } from '$lib/secrets/secretsService';
5455
import { IdSelection } from '$lib/selection/idSelection.svelte';
5556
import { UncommittedService } from '$lib/selection/uncommittedService.svelte';
@@ -156,6 +157,7 @@
156157
forgeFactory,
157158
uiState
158159
);
160+
const rulesService = new RulesService(clientState['backendApi']);
159161
const actionService = new ActionService(clientState['backendApi']);
160162
const oplogService = new OplogService(clientState['backendApi']);
161163
const baseBranchService = new BaseBranchService(clientState.backendApi);
@@ -251,6 +253,7 @@
251253
setContext(AppSettings, data.appSettings);
252254
setContext(EventContext, data.eventContext);
253255
setContext(StackService, stackService);
256+
setContext(RulesService, rulesService);
254257
setContext(ActionService, actionService);
255258
setContext(OplogService, oplogService);
256259
setContext(BaseBranchService, baseBranchService);

0 commit comments

Comments
 (0)